<?php
/**
 * UserResourceTeardown — comprehensive, idempotent removal of every resource
 * owned by a WHP panel user. Used by:
 *
 *   - usermgmt::delete_user(), to ensure deleting a user actually deletes
 *     their sites, containers, databases, MySQL/PostgreSQL accounts,
 *     HAProxy state, DNS zones, and every per-user row across whp.*.
 *
 *   - scripts/updates/staging/cleanup-orphan-user-resources.php, to remediate
 *     servers where an earlier delete_user() left orphans behind (the entire
 *     reason this class exists; see the 2026-05-31 import-rollout incident).
 *
 * Design notes
 * ------------
 * - Idempotent: every step uses IF EXISTS / "no-op when nothing matches".
 *   Re-running tearDown() on a fully cleaned user is a successful no-op.
 *
 * - Best-effort: each subsystem is wrapped in its own try/catch. A failure
 *   in one (e.g. PostgreSQL not configured on this server) does NOT abort
 *   the rest. Errors accumulate in the returned report; the caller decides
 *   how to surface them.
 *
 * - Reads username from caller — does NOT call validate_username_or_throw()
 *   directly. Callers (usermgmt::delete_user, cleanup script) validate first.
 *
 * - Does NOT touch: filesystem (/docker/users/<name>), system user (userdel),
 *   mail spool, system quota, the top-level 'user'@'%' MySQL account,
 *   client_allowances row, user_passwords row. Those remain in the caller
 *   because they fall outside the "owned resources" scope and the caller
 *   has stricter ordering requirements around shell-outs.
 */

class UserResourceTeardown {

    private $pdo;
    private $logger;

    /**
     * @param PDO|null $pdo  Optional WHP MySQL connection. If null, the
     *                       constructor opens one via mysqlmgmt.
     * @param callable|null $logger  Optional logger(string $msg).
     *                               Defaults to error_log().
     */
    public function __construct(PDO $pdo = null, callable $logger = null) {
        if ($pdo === null) {
            require_once('/docker/whp/web/libs/mysqlmgmt.php');
            $pdo = (new mysqlmgmt())->getMySQLConnection();
            if (!$pdo) {
                throw new RuntimeException('UserResourceTeardown: cannot open MySQL connection');
            }
        }
        $this->pdo = $pdo;
        $this->logger = $logger ?: function ($msg) {
            error_log('[user-teardown] ' . $msg);
        };
    }

    private function log($msg) {
        ($this->logger)($msg);
    }

    /**
     * Tear down every owned resource for $username.
     *
     * @param string $username  Panel username (caller-validated)
     * @return array {
     *   sites:    [ {id, removed, errors[]}, ... ],
     *   databases: [ name, ... ],
     *   db_users: [ name, ... ],
     *   pg_user:  string|null,
     *   pg_databases: [ name, ... ],
     *   rows:     { table_name => count_deleted, ... },
     *   errors:   [ string, ... ],
     * }
     */
    public function tearDown($username) {
        $report = [
            'sites'        => [],
            'databases'    => [],
            'db_users'     => [],
            'pg_user'      => null,
            'pg_databases' => [],
            'rows'         => [],
            'errors'       => [],
        ];

        // 1. Sites + containers + HAProxy + DNS challenges. Each site is
        //    independent — one failing should not block the rest.
        $report['sites'] = $this->tearDownSites($username, $report['errors']);

        // 2. Domains: per-domain haproxy + DNS teardown, then drop the rows.
        //    Required as a standalone step (not just nested in sites) because
        //    the cleanup-script flow runs after sites are already gone, leaving
        //    haproxy_domains / haproxy_backend_servers / DNS zones orphaned.
        $report['rows']['domains'] = $this->tearDownDomains($username, $report['errors']);

        // 4. MySQL sub-users (e.g. user_wpuser@%).
        $report['db_users'] = $this->tearDownMysqlSubUsers($username, $report['errors']);

        // 5. MySQL databases owned by this user, identified by the
        //    tracking table OR by 'user_' name prefix. Union covers both
        //    cpanel-import flow (tracking-table populated) and any legacy
        //    pre-tracking databases.
        $report['databases'] = $this->tearDownMysqlDatabases($username, $report['errors']);

        // 6. PostgreSQL.
        $pg = $this->tearDownPostgres($username, $report['errors']);
        $report['pg_user'] = $pg['user'];
        $report['pg_databases'] = $pg['databases'];

        // 7. Per-user whp.* tracking rows. Best-effort each.
        $report['rows'] = array_merge(
            $report['rows'],
            $this->tearDownTrackingRows($username, $report['errors'])
        );

        return $report;
    }

    // -- sites --------------------------------------------------------------

    private function tearDownSites($username, array &$errors) {
        $results = [];
        try {
            $stmt = $this->pdo->prepare(
                "SELECT id, site_name, is_wildcard FROM whp.sites WHERE username = ?"
            );
            $stmt->execute([$username]);
            $sites = $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (Throwable $e) {
            $errors[] = 'sites: ' . $e->getMessage();
            return $results;
        }

        foreach ($sites as $site) {
            $results[] = $this->tearDownOneSite((int)$site['id'], (bool)$site['is_wildcard'], $errors);
        }
        return $results;
    }

    private function tearDownOneSite($siteId, $isWildcard, array &$errors) {
        $r = ['site_id' => $siteId, 'containers_removed' => 0, 'errors' => []];

        // Stop + remove docker containers for this site.
        try {
            require_once('/docker/whp/web/libs/docker-api.php');
            $docker = new docker_api();
            $stmt = $this->pdo->prepare(
                "SELECT container_id FROM whp.site_containers WHERE site_id = ?"
            );
            $stmt->execute([$siteId]);
            foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $cid) {
                try {
                    $docker->stop_container($cid);
                } catch (Throwable $e) { /* best-effort */ }
                try {
                    $docker->remove_container($cid);
                    $r['containers_removed']++;
                } catch (Throwable $e) {
                    $r['errors'][] = "container $cid: " . $e->getMessage();
                }
            }
        } catch (Throwable $e) {
            $r['errors'][] = 'docker: ' . $e->getMessage();
        }

        // HAProxy cleanup (ACLs + backend in the haproxy-manager container).
        try {
            require_once('/docker/whp/web/libs/haproxy_manager.php');
            $hap = new haproxy_manager();
            $res = $hap->removeSite($siteId);
            if (empty($res['success'])) {
                $r['errors'][] = 'haproxy: ' . ($res['error'] ?? 'unknown');
            }
        } catch (Throwable $e) {
            $r['errors'][] = 'haproxy: ' . $e->getMessage();
        }

        // Wildcard DNS + ACME-challenge cleanup, mirroring delete_site flow.
        if ($isWildcard) {
            try {
                require_once('/docker/whp/web/libs/pdns-integration.php');
                $pdns = new PowerDNSIntegration($this->pdo);
                $stmt = $this->pdo->prepare(
                    "SELECT fqdn FROM whp.site_domains WHERE site_id = ? AND is_primary = 1"
                );
                $stmt->execute([$siteId]);
                $fqdn = $stmt->fetchColumn();
                if ($fqdn && strpos($fqdn, '*.') === 0) {
                    $base = substr($fqdn, 2);
                    $pdns->removeWildcardRecord($base);
                    $pdns->removeDnsChallengeRecord($base);
                }
            } catch (Throwable $e) {
                $r['errors'][] = 'pdns wildcard: ' . $e->getMessage();
            }
        }

        // Drop the sites row. This cascades site_domains, site_containers,
        // and dns_challenges via their FKs.
        try {
            $stmt = $this->pdo->prepare("DELETE FROM whp.sites WHERE id = ?");
            $stmt->execute([$siteId]);
            $r['removed'] = true;
        } catch (Throwable $e) {
            $r['removed'] = false;
            $r['errors'][] = 'delete sites row: ' . $e->getMessage();
        }

        if ($r['errors']) {
            $errors[] = "site $siteId: " . implode('; ', $r['errors']);
        }
        return $r;
    }

    // -- domains ------------------------------------------------------------

    private function tearDownDomains($username, array &$errors) {
        // Collect the domain list once — used for haproxy + pdns + the
        // per-FQDN sweep, all of which lose their key after the DELETE.
        $domains = [];
        try {
            $stmt = $this->pdo->prepare(
                "SELECT domain_name FROM whp.domains WHERE username = ?"
            );
            $stmt->execute([$username]);
            $domains = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
        } catch (Throwable $e) {
            $errors[] = 'domain enum: ' . $e->getMessage();
        }

        // Build the full FQDN set (parent + site subdomain FQDNs) so we can
        // sweep haproxy_domains rows whose names aren't equal to the parent
        // domain (e.g. <addon>.<main-domain>.tld).
        $fqdns = $domains;
        try {
            $stmt = $this->pdo->prepare(
                "SELECT DISTINCT sd.fqdn
                   FROM whp.site_domains sd
                   JOIN whp.domains d ON sd.domain_id = d.id
                  WHERE d.username = ? AND sd.fqdn IS NOT NULL"
            );
            $stmt->execute([$username]);
            foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $f) {
                $fqdns[] = $f;
            }
        } catch (Throwable $e) {
            // site_domains may already be gone (cleanup-script flow) — fine.
        }
        $fqdns = array_values(array_unique(array_filter($fqdns, 'strlen')));

        // Per-FQDN haproxy_manager removal — clears the in-container ACLs
        // and removes the haproxy_domains / haproxy_backend_servers rows.
        try {
            require_once('/docker/whp/web/libs/haproxy_manager.php');
            $hap = new haproxy_manager();
            foreach ($fqdns as $fqdn) {
                try {
                    $hap->removeDomain($fqdn);
                } catch (Throwable $e) {
                    $errors[] = "haproxy removeDomain $fqdn: " . $e->getMessage();
                }
            }
        } catch (Throwable $e) {
            $errors[] = 'haproxy init: ' . $e->getMessage();
        }

        // Defensive: any haproxy_domains row whose name is one of our FQDNs
        // and survived removeDomain (e.g. API was unreachable) gets force-deleted.
        if ($fqdns) {
            $placeholders = implode(',', array_fill(0, count($fqdns), '?'));
            try {
                $stmt = $this->pdo->prepare(
                    "SELECT DISTINCT backend_name FROM whp.haproxy_domains
                      WHERE domain_name IN ($placeholders)"
                );
                $stmt->execute($fqdns);
                $backends = $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
                if ($backends) {
                    $bp = implode(',', array_fill(0, count($backends), '?'));
                    $this->pdo->prepare("DELETE FROM whp.haproxy_backend_servers WHERE backend_name IN ($bp)")
                              ->execute($backends);
                }
                $this->pdo->prepare("DELETE FROM whp.haproxy_domains WHERE domain_name IN ($placeholders)")
                          ->execute($fqdns);
            } catch (Throwable $e) {
                $errors[] = 'haproxy sweep: ' . $e->getMessage();
            }
        }

        // PowerDNS zone removal per parent domain.
        try {
            require_once('/docker/whp/web/libs/pdns-integration.php');
            $pdns = new PowerDNSIntegration($this->pdo);
            foreach ($domains as $domain) {
                try {
                    $pdns->deleteZoneForDomain($domain, $username);
                } catch (Throwable $e) {
                    $errors[] = "pdns zone $domain: " . $e->getMessage();
                }
            }
        } catch (Throwable $e) {
            $errors[] = 'pdns init: ' . $e->getMessage();
        }

        return $this->safeDeleteRows(
            "DELETE FROM whp.domains WHERE username = ?",
            [$username],
            'domains',
            $errors
        );
    }

    // -- MySQL --------------------------------------------------------------

    private function tearDownMysqlSubUsers($username, array &$errors) {
        $dropped = [];
        try {
            // mysql.user.User LIKE 'username\_%' captures every sub-user
            // (the underscore is the separator created by createDatabaseUser).
            // The top-level 'username'@'%' is dropped by the caller's
            // existing deleteMySQLUser() flow.
            $stmt = $this->pdo->prepare(
                "SELECT User, Host FROM mysql.user
                  WHERE User LIKE CONCAT(REPLACE(?, '_', '\\_'), '\\_%')
                  ORDER BY User, Host"
            );
            $stmt->execute([$username]);
            foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
                $user = $row['User'];
                $host = $row['Host'];
                $userQ = "'" . addslashes($user) . "'";
                $hostQ = "'" . addslashes($host) . "'";
                try {
                    $this->pdo->exec("DROP USER IF EXISTS $userQ@$hostQ");
                    $dropped[] = "$user@$host";
                } catch (Throwable $e) {
                    $errors[] = "mysql sub-user $user@$host: " . $e->getMessage();
                }
            }
            if ($dropped) {
                $this->pdo->exec("FLUSH PRIVILEGES");
            }
        } catch (Throwable $e) {
            $errors[] = 'mysql sub-users: ' . $e->getMessage();
        }
        return $dropped;
    }

    private function tearDownMysqlDatabases($username, array &$errors) {
        $dropped = [];
        $candidates = [];
        try {
            // (a) databases tracked in whp.user_databases
            $stmt = $this->pdo->prepare(
                "SELECT database_name FROM whp.user_databases WHERE owner_username = ?"
            );
            $stmt->execute([$username]);
            foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $db) {
                $candidates[$db] = true;
            }
            // (b) databases whose schema name starts with 'username_'
            //     (cpanel-import naming convention and legacy pattern).
            $stmt = $this->pdo->prepare(
                "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA
                  WHERE SCHEMA_NAME LIKE CONCAT(REPLACE(?, '_', '\\_'), '\\_%')"
            );
            $stmt->execute([$username]);
            foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $db) {
                $candidates[$db] = true;
            }
        } catch (Throwable $e) {
            $errors[] = 'mysql db enum: ' . $e->getMessage();
            return $dropped;
        }

        foreach (array_keys($candidates) as $db) {
            if (!preg_match('/^[A-Za-z0-9_][A-Za-z0-9_$]*$/', $db)) {
                $errors[] = "mysql db skipped (bad name): $db";
                continue;
            }
            try {
                $this->pdo->exec("DROP DATABASE IF EXISTS `$db`");
                $dropped[] = $db;
            } catch (Throwable $e) {
                $errors[] = "mysql db $db: " . $e->getMessage();
            }
        }
        return $dropped;
    }

    // -- PostgreSQL ---------------------------------------------------------

    private function tearDownPostgres($username, array &$errors) {
        $out = ['user' => null, 'databases' => []];
        if (!file_exists('/docker/whp/web/libs/pgsqlmgmt.php')) {
            return $out;
        }
        try {
            require_once('/docker/whp/web/libs/pgsqlmgmt.php');
            $pg = new pgsqlmgmt();
            $pgPdo = $pg->getPostgreSQLConnection();
            if (!$pgPdo) {
                // PG not configured here — quietly skip.
                return $out;
            }

            // Drop owned databases first.
            $stmt = $pgPdo->prepare(
                "SELECT datname FROM pg_database
                  WHERE pg_get_userbyid(datdba) = ?"
            );
            $stmt->execute([$username]);
            foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $dbname) {
                try {
                    $r = $pg->deleteDatabase($dbname);
                    if (($r['status'] ?? '1') === '0') {
                        $out['databases'][] = $dbname;
                    } else {
                        $errors[] = "pg db $dbname: " . ($r['msg'] ?? 'unknown');
                    }
                } catch (Throwable $e) {
                    $errors[] = "pg db $dbname: " . $e->getMessage();
                }
            }

            // Then drop the user (deletePostgreSQLUser does DROP OWNED + DROP USER).
            $stmt = $pgPdo->prepare("SELECT 1 FROM pg_user WHERE usename = ?");
            $stmt->execute([$username]);
            if ($stmt->fetchColumn()) {
                $r = $pg->deletePostgreSQLUser($username);
                if (($r['status'] ?? '1') === '0') {
                    $out['user'] = $username;
                } else {
                    $errors[] = 'pg user: ' . ($r['msg'] ?? 'unknown');
                }
            }
        } catch (Throwable $e) {
            $errors[] = 'pg: ' . $e->getMessage();
        }
        return $out;
    }

    // -- whp.* tracking rows ------------------------------------------------

    /**
     * Each entry: [table, column, where_value]. Tables/columns missing on
     * older deployments are silently skipped.
     */
    private function trackingTables($username) {
        return [
            ['user_databases',        'owner_username',  $username],
            ['database_users',        'created_by',      $username],
            ['wordpress_sites',       'user',            $username],
            ['cpanel_imports',        'username',        $username],
            ['email_accounts',        'username',        $username],
            ['email_aliases',         'username',        $username],
            ['email_forwarders',      'username',        $username],
            ['email_archival_events', 'username',        $username],
            ['ftp_users',             'username',        $username],
            ['ftp_quotas',            'username',        $username],
            ['ftp_access_log',        'username',        $username],
            ['backup_schedules',      'username',        $username],
            ['backup_targets',        'username',        $username],
            ['backup_history',        'username',        $username],
            ['backup_queue',          'username',        $username],
            ['backup_upload_queue',   'username',        $username],
            ['backup_local_storage',  'username',        $username],
            ['site_log_positions',    'username',        $username],
            ['site_disable_audit',    'username',        $username],
            ['site_builder_projects', 'username',        $username],
            ['sitesmith_threads',     'username',        $username],
            ['sitesmith_usage',       'username',        $username],
            ['sso_tokens',            'username',        $username],
            ['api_keys',              'created_by',      $username],
            ['customer_valkey',       'username',        $username],
            ['container_user_variables', 'username',     $username],
            ['user_permissions',      'username',        $username],
            ['delegated_users',       'parent_username', $username],
            ['terminal_preferences',  'username',        $username],
            ['terminal_access_log',   'username',        $username],
            ['account_disable_audit', 'username',        $username],
            ['accounts_disabled',     'username',        $username],
        ];
    }

    private function tearDownTrackingRows($username, array &$errors) {
        $out = [];
        // sitesmith_messages has no username column; it's keyed by thread_id.
        // Wipe it BEFORE deleting sitesmith_threads. Subquery form (not
        // multi-table DELETE) so it runs without a default-DB context —
        // 2026.05.203 cleanup-on-whp02 hit "1046 No database selected" with
        // the JOIN form, because the PDO connection has no default DB set.
        try {
            $stmt = $this->pdo->prepare(
                "DELETE FROM whp.sitesmith_messages
                  WHERE thread_id IN (
                      SELECT id FROM whp.sitesmith_threads WHERE username = ?
                  )"
            );
            $stmt->execute([$username]);
            $out['sitesmith_messages'] = $stmt->rowCount();
        } catch (Throwable $e) {
            if (!$this->isMissingTableError($e)) {
                $errors[] = 'sitesmith_messages: ' . $e->getMessage();
            }
        }

        foreach ($this->trackingTables($username) as [$table, $col, $val]) {
            $out[$table] = $this->safeDeleteRows(
                "DELETE FROM whp.`$table` WHERE `$col` = ?",
                [$val],
                $table,
                $errors
            );
        }
        return $out;
    }

    private function safeDeleteRows($sql, array $params, $label, array &$errors) {
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute($params);
            return $stmt->rowCount();
        } catch (Throwable $e) {
            if ($this->isMissingTableError($e) || $this->isMissingColumnError($e)) {
                // Schema drift across deployments — silently skip.
                return 0;
            }
            $errors[] = "$label: " . $e->getMessage();
            return 0;
        }
    }

    private function isMissingTableError(Throwable $e) {
        // MySQL 1146 = ER_NO_SUCH_TABLE
        return str_contains($e->getMessage(), '1146')
            || str_contains($e->getMessage(), "doesn't exist");
    }

    private function isMissingColumnError(Throwable $e) {
        // MySQL 1054 = ER_BAD_FIELD_ERROR
        return str_contains($e->getMessage(), '1054')
            || str_contains($e->getMessage(), 'Unknown column');
    }
}
