<?php
// /docker/whp/web/libs/site_disable.php
// Atomic site disable/enable. Order on disable: state_file -> haproxy -> stop -> audit.
// Order on enable: state_file -> start -> wait -> haproxy un-suspend -> audit.
//
// Schema notes (adapted from plan placeholders to actual whp schema):
//   - Container name comes from whp.sites.username (VARCHAR 32), NOT a separate column.
//   - Domains come from whp.site_domains (site_id FK -> sites.id, fqdn column) joined with
//     whp.domains (id FK). The plan's placeholder "whp.domains WHERE site_id=:id" does not
//     match the real schema; the correct path is whp.site_domains.fqdn WHERE site_id=:id.
//   - PDO factory: (new mysqlmgmt())->getMySQLConnection() — no global whp_pdo() exists.
//
// HAProxy reroute note: disable_site() writes /etc/haproxy/suspended_domains.list and
// SIGHUPs haproxy-manager. This is a bridge implementation; full ACL routing via a
// suspended_domains.list ACL in hap_listener.tpl is a follow-on PR in haproxy-manager-base.
//
// State note: dynamic state (autostart, disabled_reason, disabled_at, disabled_by) lives in
// /var/lib/whp/customer-state.json rather than Docker labels. docker update --label-add/rm
// is unsupported on plain containers (only docker service update for Swarm). v109 pivot.

require_once(__DIR__ . '/mysqlmgmt.php');
require_once(__DIR__ . '/docker.php');
require_once(__DIR__ . '/customer_state.php');

const SITE_DISABLE_REASONS = ['unpaid', 'tos_violation', 'maintenance', 'admin_other'];

function _whp_db(): PDO {
    $pdo = (new mysqlmgmt())->getMySQLConnection();
    if (!$pdo) {
        throw new RuntimeException('site_disable: database connection failed');
    }
    return $pdo;
}

function _container_name_for_site(int $site_id): ?string {
    // Customer container name is NOT the site's username column. The WHP
    // naming convention is {whp.domain}-{whp.container_number:02d} (set by
    // docker-api.php at creation). The most-reliable lookup is by the
    // whp.site_id docker label.
    $r = whp_docker(['ps', '-aq', '--filter', "label=whp.site_id={$site_id}"]);
    $ids = array_filter(array_map('trim', explode("\n", $r['stdout'] ?? '')));
    foreach ($ids as $id) {
        if ($id === '') continue;
        $n = whp_docker(['inspect', '-f', '{{.Name}}', $id]);
        $name = ltrim(trim($n['stdout'] ?? ''), '/');
        if ($name !== '') return $name;
    }
    return null;
}

function _domains_for_site(int $site_id): array {
    $db = _whp_db();
    // whp.domains has no site_id column; domain FQDNs for a site live in
    // whp.site_domains.fqdn (site_id FK -> sites.id).
    $stmt = $db->prepare(
        "SELECT sd.fqdn FROM whp.site_domains sd WHERE sd.site_id = :id AND sd.fqdn IS NOT NULL AND sd.fqdn != ''"
    );
    $stmt->execute(['id' => $site_id]);
    $out = [];
    foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
        $out[] = $r['fqdn'];
    }
    return $out;
}

function _haproxy_suspended_list_update(string $action, array $domains): bool {
    // haproxy-manager runs with -v haproxy:/etc/haproxy (named Docker volume).
    // Writing to the host path /etc/haproxy has no effect on the container's view.
    // Read the current list from inside the container, stage a new copy in /tmp,
    // then docker-cp it in. This is race-safe and volume-path-agnostic.

    $tmp_host = sys_get_temp_dir() . '/whp-suspended-list-' . getmypid() . '.txt';

    // Read current list from the container
    $cur = whp_docker(['exec', 'haproxy-manager', 'sh', '-c',
        'test -f /etc/haproxy/suspended_domains.list && cat /etc/haproxy/suspended_domains.list || true']);
    $existing = ($cur['rc'] === 0 && $cur['stdout'] !== '')
        ? array_filter(array_map('trim', explode("\n", $cur['stdout'])))
        : [];

    if ($action === 'add') {
        $existing = array_values(array_unique(array_merge($existing, $domains)));
    } else {
        $existing = array_values(array_diff($existing, $domains));
    }

    if (file_put_contents($tmp_host, implode("\n", $existing) . "\n") === false) {
        error_log("[site_disable] failed to stage suspended_domains.list at $tmp_host");
        return false;
    }

    $cp = whp_docker(['cp', $tmp_host, 'haproxy-manager:/etc/haproxy/suspended_domains.list']);
    @unlink($tmp_host);
    if ($cp['rc'] !== 0) {
        error_log("[site_disable] docker cp failed: " . ($cp['stderr'] ?? ''));
        return false;
    }

    // Hitless HAProxy reload. `docker kill --signal=HUP haproxy-manager` sends
    // SIGHUP to PID 1 of the container — but PID 1 is gunicorn (the haproxy-
    // manager API server), not haproxy itself, so HAProxy never sees the
    // change. Reload via the master CLI socket inside the container; fall
    // back to SIGUSR2 on the haproxy PID file if the socket isn't available
    // (e.g. older haproxy-manager image without -W -S setup).
    $hup = whp_docker(['exec', 'haproxy-manager', 'sh', '-c',
        'echo "reload" | socat - /tmp/haproxy-cli 2>/dev/null '
        . '|| kill -USR2 "$(cat /var/run/haproxy.pid)"']);
    return $hup['rc'] === 0;
}

function _wait_until_serving(string $container, int $timeout_sec): bool {
    // Pin to client-net to avoid concatenating IPs from multiple networks when
    // the container is attached to more than one bridge (e.g. client-net + default).
    $r = whp_docker(['inspect', '-f',
        '{{with index .NetworkSettings.Networks "client-net"}}{{.IPAddress}}{{end}}',
        $container]);
    $ip = trim($r['stdout'] ?? '');
    if ($ip === '') return false;
    $deadline = time() + $timeout_sec;
    while (time() < $deadline) {
        $sock = @fsockopen($ip, 80, $err_no, $err_str, 1.0);
        if ($sock) { fclose($sock); return true; }
        usleep(500000);
    }
    return false;
}

function disable_site(int $site_id, string $reason, string $actor, ?string $note = null): array {
    if (!in_array($reason, SITE_DISABLE_REASONS, true)) {
        return ['ok' => false, 'error' => 'invalid_reason'];
    }
    $container = _container_name_for_site($site_id);
    if (!$container) return ['ok' => false, 'error' => 'site_not_found'];

    // 1. State file: mark disabled, record reason/actor/timestamp
    $now = gmdate('c');
    $ok = whp_state_modify(function (array $state) use ($container, $reason, $actor, $now): array {
        $state[$container] = [
            'autostart'       => false,
            'disabled_reason' => $reason,
            'disabled_at'     => $now,
            'disabled_by'     => $actor,
        ];
        return $state;
    });
    if (!$ok) return ['ok' => false, 'error' => 'state_write_failed'];

    // 2. HAProxy: reroute domains to suspended backend
    $domains = _domains_for_site($site_id);
    if (!_haproxy_suspended_list_update('add', $domains)) {
        return ['ok' => false, 'error' => 'haproxy_update_failed'];
    }

    // 3. Stop container
    whp_docker(['stop', '-t', '30', $container]);

    // 4. Audit
    $db = _whp_db();
    $stmt = $db->prepare(
        "INSERT INTO whp.site_disable_audit (site_id, action, reason, actor, note) VALUES (:id, 'disable', :r, :a, :n)"
    );
    $stmt->execute(['id' => $site_id, 'r' => $reason, 'a' => $actor, 'n' => $note]);

    return ['ok' => true];
}

function enable_site(int $site_id, string $actor): array {
    $container = _container_name_for_site($site_id);
    if (!$container) return ['ok' => false, 'error' => 'site_not_found'];

    // 1. State file: re-enable autostart, clear disabled fields (keep entry for auditability)
    $ok = whp_state_modify(function (array $state) use ($container): array {
        $state[$container] = [
            'autostart'       => true,
            'disabled_reason' => null,
            'disabled_at'     => null,
            'disabled_by'     => null,
        ];
        return $state;
    });
    if (!$ok) return ['ok' => false, 'error' => 'state_write_failed'];

    // 2. Start container
    $r = whp_docker(['start', $container]);
    if ($r['rc'] !== 0) return ['ok' => false, 'error' => 'docker_start_failed'];

    // 3. Wait until serving on port 80 (container is on client-net bridge, IP reachable from host)
    if (!_wait_until_serving($container, 20)) {
        return ['ok' => false, 'error' => 'container_not_serving'];
    }

    // 4. HAProxy: remove domains from suspended list
    $domains = _domains_for_site($site_id);
    if (!_haproxy_suspended_list_update('remove', $domains)) {
        return ['ok' => false, 'error' => 'haproxy_update_failed'];
    }

    // 5. Audit
    $db = _whp_db();
    $stmt = $db->prepare(
        "INSERT INTO whp.site_disable_audit (site_id, action, actor) VALUES (:id, 'enable', :a)"
    );
    $stmt->execute(['id' => $site_id, 'a' => $actor]);

    return ['ok' => true];
}
