<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   The auth-stack control layer. Drives the three services behind every protected hostname:
//   Nginx Proxy Manager (proxy hosts and certificates), LLDAP (users and groups), and
//   Authelia (access-control rules). Read and write.
//
// OPERATIONAL MODEL
//   The only include/ file that routinely mutates external state. Everything else here
//   reports; this one creates users, rewrites proxy hosts, edits Authelia's YAML, and
//   restarts the Authelia container. Treat every function below as load-bearing.
//
// DESIGN PRINCIPLES
//   Credentials come from conf, never from the page.
//       NPM and LLDAP credentials are read from host*.conf. The browser never sees them and
//       never supplies them.
//
//   Tokens are cached per session, not per request.
//       NPM and LLDAP tokens are held in $_SESSION with a 23-hour expiry, so a page that
//       makes twelve calls authenticates once. Expiry is checked before reuse.
//
//   Authelia is edited as text, not parsed and re-emitted.
//       Only the access_control block is rewritten, in place. Round-tripping the whole YAML
//       through a parser would silently reformat and drop comments from a file that is
//       hand-maintained and synced between hosts.
//
//   The owner host is the source of truth for auth config.
//       Changes are made here and reach the partner through Critical-Data sync, not by
//       writing to two hosts from the browser.
//
// OPERATIONAL SAFEGUARDS
//   The Authelia config write is atomic and reversible up to the last step.
//       Existence check → read → regex replace → write .vv.tmp → rename() into place. A
//       failure at any stage returns an error and leaves the original untouched; a failed
//       rename unlinks the temp file rather than leaving it beside the real config.
//
//   A missing config file is refused, never created.
//       Both the read and write paths return 'Config not found' rather than writing a fresh
//       file. Creating one would hand Authelia a config with no rules and a default policy —
//       an accidental open door. See HOST*_AUTHELIA_CONFIG below.
//
//   The container restart is shell-escaped.
//       The container name comes from conf and is passed through escapeshellarg(), so a
//       malformed conf value cannot become a command.
//
//   Auth failure is reported, not retried into a lockout.
//       A failed token fetch returns an _err string immediately. Nothing loops on bad
//       credentials against a service that may rate-limit or lock the account.
//
//   Every remote call has a timeout, and every function returns a structured result —
//   ['ok' => bool] or an _err key — so no caller has to distinguish an exception from a
//   legitimately empty list.
//
// EXPORTS
//   Config    vv_auth_conf()
//   NPM       vv_npm_list_proxies(), vv_npm_list_certs(), vv_npm_create_proxy(),
//             vv_npm_update_proxy(), vv_npm_delete_proxy(), vv_npm_toggle_proxy()
//   LLDAP     vv_lldap_list_users(), vv_lldap_list_groups(), vv_lldap_create_user(),
//             vv_lldap_update_user(), vv_lldap_delete_user(), vv_lldap_set_password(),
//             vv_lldap_create_group(), vv_lldap_delete_group(),
//             vv_lldap_add_to_group(), vv_lldap_remove_from_group()
//   Authelia  vv_authelia_read_rules(), vv_authelia_write_rules()
//
// CONFIGURATION
//   HOST*_NPM_URL         admin API — port 7818. Port 81 is the partnership WebUI port
//                         (HOST*_PARTNERSHIP_AUTH_WEBUIS), not the API. Easy to confuse.
//   HOST*_NPM_USER / _NPM_PASS
//   HOST*_LLDAP_URL / _LLDAP_USER / _LLDAP_PASS
//   HOST*_AUTHELIA_CONFIG     path to configuration.yml. Lives in the Critical-Data share so
//                             it is covered by the 30-minute auth sync — not under
//                             /mnt/user/appdata, which is not synced.
//   HOST*_AUTHELIA_CONTAINER  restarted after a successful rules write
// ═══════════════════════════════════════════════════════════════════════════════════════════════

require_once __DIR__ . '/config.php';

// ── Config ────────────────────────────────────────────────────────────────────

function vv_auth_conf(): array {
    $v    = vv_conf_vars();
    $host = strtoupper(vv_detect_host());
    return [
        'npm_url'            => rtrim($v["{$host}_NPM_URL"]            ?? 'http://localhost:7818', '/'),
        'npm_user'           => $v["{$host}_NPM_USER"]                 ?? '',
        'npm_pass'           => $v["{$host}_NPM_PASS"]                 ?? '',
        'lldap_url'          => rtrim($v["{$host}_LLDAP_URL"]          ?? 'http://localhost:17170', '/'),
        'lldap_user'         => $v["{$host}_LLDAP_USER"]               ?? '',
        'lldap_pass'         => $v["{$host}_LLDAP_PASS"]               ?? '',
        'authelia_config'    => $v["{$host}_AUTHELIA_CONFIG"]          ?? '/mnt/user/appdata-Fallback/Critical-Data/Authelia/configuration.yml',
        'authelia_container' => $v["{$host}_AUTHELIA_CONTAINER"]       ?? 'Authelia',
        'is_owner'           => vv_is_owner(),
    ];
}

// ── NPM ───────────────────────────────────────────────────────────────────────

function vv_npm_token(): string {
    if (!session_id()) session_start();
    $conf   = vv_auth_conf();
    $cached = $_SESSION['vv_npm_token']     ?? '';
    $expiry = $_SESSION['vv_npm_token_exp'] ?? 0;
    if ($cached && time() < $expiry) return $cached;

    $resp  = vv_npm_raw('POST', '/api/tokens', [
        'identity' => $conf['npm_user'],
        'secret'   => $conf['npm_pass'],
    ], '', $conf);
    $token = $resp['token'] ?? '';
    if ($token) {
        $_SESSION['vv_npm_token']     = $token;
        $_SESSION['vv_npm_token_exp'] = time() + 82800;
    }
    return $token;
}

function vv_npm_raw(string $method, string $path, array $data, string $token, array $conf = []): array {
    if (!$conf) $conf = vv_auth_conf();
    $url     = $conf['npm_url'] . $path;
    $headers = ['Content-Type: application/json', 'Accept: application/json'];
    if ($token) $headers[] = 'Authorization: Bearer ' . $token;

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_CUSTOMREQUEST  => $method,
    ]);
    if ($data && in_array($method, ['POST', 'PUT'], true))
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    $body = curl_exec($ch);
    curl_close($ch);
    return json_decode($body ?: '{}', true) ?: [];
}

function vv_npm_req(string $method, string $path, array $data = []): array {
    $token = vv_npm_token();
    if (!$token) return ['_err' => 'NPM auth failed — check credentials in host conf'];
    return vv_npm_raw($method, $path, $data, $token);
}

function vv_npm_list_proxies(): array {
    $list = vv_npm_req('GET', '/api/nginx/proxy-hosts?expand=certificate');
    if (!is_array($list) || isset($list['_err']))
        return ['ok' => false, 'error' => $list['_err'] ?? 'Invalid response from NPM'];
    return ['ok' => true, 'proxies' => $list];
}

function vv_npm_list_certs(): array {
    $list = vv_npm_req('GET', '/api/nginx/certificates');
    return is_array($list) ? $list : [];
}

function vv_npm_create_proxy(array $data): array {
    $r = vv_npm_req('POST', '/api/nginx/proxy-hosts', $data);
    return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Create failed')];
}

function vv_npm_update_proxy(int $id, array $data): array {
    $r = vv_npm_req('PUT', "/api/nginx/proxy-hosts/$id", $data);
    return isset($r['id']) ? ['ok' => true, 'proxy' => $r] : ['ok' => false, 'error' => $r['error'] ?? ($r['_err'] ?? 'Update failed')];
}

function vv_npm_delete_proxy(int $id): array {
    vv_npm_req('DELETE', "/api/nginx/proxy-hosts/$id");
    return ['ok' => true];
}

function vv_npm_toggle_proxy(int $id, bool $enabled): array {
    vv_npm_req('POST', "/api/nginx/proxy-hosts/$id/" . ($enabled ? 'enable' : 'disable'));
    return ['ok' => true];
}

// ── lldap ─────────────────────────────────────────────────────────────────────

function vv_lldap_token(): string {
    if (!session_id()) session_start();
    $conf   = vv_auth_conf();
    $cached = $_SESSION['vv_lldap_token']     ?? '';
    $expiry = $_SESSION['vv_lldap_token_exp'] ?? 0;
    if ($cached && time() < $expiry) return $cached;

    $ch = curl_init($conf['lldap_url'] . '/auth/simple/login');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(['username' => $conf['lldap_user'], 'password' => $conf['lldap_pass']]),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    ]);
    $body  = curl_exec($ch);
    curl_close($ch);
    $resp  = json_decode($body ?: '{}', true) ?: [];
    $token = $resp['token'] ?? '';
    if ($token) {
        $_SESSION['vv_lldap_token']     = $token;
        $_SESSION['vv_lldap_token_exp'] = time() + 3500;
    }
    return $token;
}

function vv_lldap_gql(string $query, array $variables = []): array {
    $conf  = vv_auth_conf();
    $token = vv_lldap_token();
    if (!$token) return ['errors' => [['message' => 'lldap auth failed — check credentials']]];

    $ch = curl_init($conf['lldap_url'] . '/api/graphql');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(['query' => $query, 'variables' => $variables]),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
    ]);
    $body = curl_exec($ch);
    curl_close($ch);
    return json_decode($body ?: '{}', true) ?: [];
}

function vv_lldap_list_users(): array {
    $r = vv_lldap_gql('query { users { id displayName email creationDate groups { id displayName } } }');
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
    return ['ok' => true, 'users' => $r['data']['users'] ?? []];
}

function vv_lldap_list_groups(): array {
    $r = vv_lldap_gql('query { groups { id displayName users { id displayName } } }');
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
    return ['ok' => true, 'groups' => $r['data']['groups'] ?? []];
}

function vv_lldap_create_user(string $id, string $email, string $displayName, string $password): array {
    $r = vv_lldap_gql(
        'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id displayName email } }',
        ['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
    );
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
    if ($password) vv_lldap_set_password($id, $password);
    return ['ok' => true, 'user' => $r['data']['createUser'] ?? []];
}

function vv_lldap_update_user(string $id, string $email, string $displayName): array {
    $r = vv_lldap_gql(
        'mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
        ['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
    );
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Update failed'];
    return ['ok' => true];
}

function vv_lldap_delete_user(string $id): array {
    $r = vv_lldap_gql(
        'mutation DeleteUser($userId: String!) { deleteUser(userId: $userId) { ok } }',
        ['userId' => $id]
    );
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
    return ['ok' => true];
}

function vv_lldap_set_password(string $userId, string $password): array {
    $conf = vv_auth_conf();
    $token = vv_lldap_token();
    if (!$token) return ['ok' => false, 'error' => 'lldap auth failed'];

    $ch = curl_init($conf['lldap_url'] . '/auth/admin/resetPassword');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(['userId' => $userId, 'password' => $password]),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
    ]);
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($code >= 200 && $code < 300) return ['ok' => true];
    $err = json_decode($body ?: '{}', true)['message'] ?? "HTTP $code";
    return ['ok' => false, 'error' => $err];
}

function vv_lldap_create_group(string $name): array {
    $r = vv_lldap_gql(
        'mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }',
        ['name' => $name]
    );
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
    return ['ok' => true, 'group' => $r['data']['createGroup'] ?? []];
}

function vv_lldap_delete_group(int $id): array {
    $r = vv_lldap_gql(
        'mutation DeleteGroup($groupId: Int!) { deleteGroup(groupId: $groupId) { ok } }',
        ['groupId' => $id]
    );
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Delete failed'];
    return ['ok' => true];
}

function vv_lldap_add_to_group(string $userId, int $groupId): array {
    $r = vv_lldap_gql(
        'mutation AddUserToGroup($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }',
        ['userId' => $userId, 'groupId' => $groupId]
    );
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
    return ['ok' => true];
}

function vv_lldap_remove_from_group(string $userId, int $groupId): array {
    $r = vv_lldap_gql(
        'mutation RemoveUserFromGroup($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }',
        ['userId' => $userId, 'groupId' => $groupId]
    );
    if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Failed'];
    return ['ok' => true];
}

// ── Authelia ──────────────────────────────────────────────────────────────────

function vv_authelia_read_rules(): array {
    $conf = vv_auth_conf();
    $file = $conf['authelia_config'];
    if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];

    $content = file_get_contents($file);
    if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file'];

    // Extract default_policy (strip inline comments)
    $defaultPolicy = 'deny';
    if (preg_match('/^[ \t]+default_policy:[ \t]+([a-z_]+)/m', $content, $m))
        $defaultPolicy = $m[1];

    // Extract the indented block under access_control:
    if (!preg_match('/^access_control:[ \t]*\n((?:[ \t][^\n]*\n?)*)/m', $content, $m))
        return ['ok' => false, 'error' => 'access_control section not found'];

    $acBlock = $m[1];

    // Extract the indented block under rules: (3+ space indent = rule list items)
    if (!preg_match('/^  rules:[ \t]*\n((?:[ \t]{3,}[^\n]*\n?)*)/m', $acBlock, $m))
        return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => []];

    // Split into individual rule chunks at "    - " (indent-4 rule starts)
    $chunks = preg_split('/(?=^    - )/m', $m[1]);
    $rules  = [];
    foreach ($chunks as $chunk) {
        if (!preg_match('/^    - /', $chunk)) continue;
        $rule = vv_authelia_parse_rule_chunk($chunk);
        if (!empty($rule)) $rules[] = $rule;
    }

    return ['ok' => true, 'default_policy' => $defaultPolicy, 'rules' => $rules];
}

function vv_authelia_parse_rule_chunk(string $chunk): array {
    $rule  = [];
    $field = null;
    $list  = [];

    $save = function () use (&$rule, &$field, &$list) {
        if ($field === null) return;
        if (!empty($list))
            $rule[$field] = count($list) === 1 ? $list[0] : $list;
        $field = null;
        $list  = [];
    };

    foreach (explode("\n", $chunk) as $line) {
        $raw    = rtrim($line);
        $trim   = trim($raw);
        if ($trim === '' || preg_match('/^#+/', $trim)) continue;
        $indent = strlen($raw) - strlen(ltrim($raw, ' '));

        // indent=4, starts with "- " → first field of this rule block
        if ($indent === 4 && str_starts_with($trim, '- ')) {
            $rest = ltrim(substr($trim, 2));
            if (preg_match('/^([a-z_]+):[ \t]*(.*)$/', $rest, $m)) {
                $save();
                $field = $m[1];
                $val   = trim($m[2]);
                if ($val !== '' && !str_starts_with($val, '#')) {
                    $rule[$field] = vv_authelia_unquote($val);
                    $field = null;
                }
            }
            continue;
        }

        // indent=6 → named field (scalar or list header)
        if ($indent === 6 && preg_match('/^([a-z_]+):[ \t]*(.*)$/', $trim, $m)) {
            $save();
            $field = $m[1];
            $val   = trim($m[2]);
            if ($val !== '' && !str_starts_with($val, '#')) {
                $rule[$field] = vv_authelia_unquote($val);
                $field = null;
            }
            continue;
        }

        // indent=8, starts with "- " → list item under current field
        if ($indent === 8 && str_starts_with($trim, '- ')) {
            $list[] = vv_authelia_parse_list_item(trim(substr($trim, 2)));
        }
    }
    $save();
    return $rule;
}

// Strip surrounding quotes and inline comments from a YAML scalar.
function vv_authelia_unquote(string $val): string {
    $val = trim($val);
    $val = preg_replace('/\s+#[^"\']*$/', '', $val); // strip trailing comment
    if (preg_match('/^(["\'])(.+)\1$/', $val, $m)) return $m[2];
    return $val;
}

// Parse a YAML list item: flow sequence ['group:name'] or plain/quoted scalar.
function vv_authelia_parse_list_item(string $val): string {
    $val = trim($val);
    // Flow sequence: ['value'] or ["value"] or [value]
    if (preg_match('/^\[[\'""]?([^\]\'""]+)[\'""]?\]$/', $val, $m)) return trim($m[1]);
    return vv_authelia_unquote($val);
}

function vv_authelia_write_rules(array $rules, string $defaultPolicy): array {
    $conf = vv_auth_conf();
    $file = $conf['authelia_config'];
    if (!file_exists($file)) return ['ok' => false, 'error' => 'Config not found: ' . $file];

    $content = file_get_contents($file);
    if ($content === false) return ['ok' => false, 'error' => 'Cannot read config file'];

    // Build the new access_control block
    $block  = "access_control:\n";
    $block .= "  default_policy: $defaultPolicy\n";
    $block .= "  rules:\n";

    // Preferred field output order
    $fieldOrder = ['domain', 'policy', 'subject', 'networks', 'resources'];

    foreach ($rules as $rule) {
        $keys  = array_merge(
            array_filter($fieldOrder, fn($k) => array_key_exists($k, $rule)),
            array_diff(array_keys($rule), $fieldOrder)
        );
        $first = true;
        foreach ($keys as $key) {
            if (!array_key_exists($key, $rule)) continue;
            $val    = $rule[$key];
            $prefix = $first ? '    - ' : '      ';
            $first  = false;

            // domain, subject, resources, networks → always output as list
            $isList = in_array($key, ['domain', 'subject', 'resources', 'networks'], true);
            if ($isList) {
                $items  = is_array($val) ? $val : [$val];
                $block .= $prefix . $key . ":\n";
                foreach ($items as $item) {
                    $out    = $key === 'subject'
                        ? "['" . $item . "']"
                        : vv_authelia_yaml_scalar((string) $item);
                    $block .= '        - ' . $out . "\n";
                }
            } else {
                $block .= $prefix . $key . ': ' . vv_authelia_yaml_scalar((string) $val) . "\n";
            }
        }
    }

    // Replace existing access_control: block (from its line to next top-level key or EOF)
    $pattern = '/^access_control:[ \t]*\n(?:[ \t][^\n]*\n?)*/m';
    $new     = preg_match($pattern, $content)
        ? preg_replace($pattern, $block, $content, 1)
        : rtrim($content) . "\n\n" . $block;

    if ($new === null) return ['ok' => false, 'error' => 'Regex replace failed'];

    $tmp = $file . '.vv.tmp';
    if (file_put_contents($tmp, $new) === false) return ['ok' => false, 'error' => 'Write failed'];
    if (!rename($tmp, $file)) { @unlink($tmp); return ['ok' => false, 'error' => 'Atomic rename failed']; }

    shell_exec('docker restart ' . escapeshellarg($conf['authelia_container']) . ' >/dev/null 2>&1 &');
    return ['ok' => true];
}

// Quote a YAML scalar value if it contains characters that require quoting.
function vv_authelia_yaml_scalar(string $val): string {
    if ($val === '' || preg_match('/[:#\[\]{},|>&*?!%@`\'"]/', $val) || preg_match('/^\s|\s$/', $val))
        return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $val) . '"';
    return $val;
}
