<?php
/**
 * election-activity-cost.php — بهای تمام‌شده فعالیت‌های کمپین انتخابات
 * کمپین ↔ فعالیت (رسانه / توزیع / میدانی / …) ↔ خدمه ستاد و هواداران
 */
if ( ! defined( 'ABSPATH' ) ) exit;

class Election_Activity_Cost {

    public static function default_activities(): array {
        return [
            [ 'code' => 'media',        'name' => 'تبلیغات و رسانه',      'pool' => 'outreach',  'weight' => 100, 'roles' => [ 'media', 'spokesperson' ],           'driver' => 'weight', 'sort' => 10 ],
            [ 'code' => 'distribution', 'name' => 'توزیع و چاپ',           'pool' => 'outreach',  'weight' => 70,  'roles' => [ 'distribution', 'goods' ],          'driver' => 'hours',  'sort' => 20 ],
            [ 'code' => 'goods',        'name' => 'اقلام تبلیغاتی',         'pool' => 'logistics', 'weight' => 80,  'roles' => [ 'goods', 'warehouse' ],             'driver' => 'hours',  'sort' => 30 ],
            [ 'code' => 'field',        'name' => 'عملیات میدانی صندوق',   'pool' => 'field',     'weight' => 100, 'roles' => [ 'supporter', 'field', 'counties' ], 'driver' => 'hours',  'sort' => 40 ],
            [ 'code' => 'staff',        'name' => 'نیروی انسانی ستاد',      'pool' => 'staff',     'weight' => 100, 'roles' => [ 'planning', 'admin', 'coord' ],     'driver' => 'weight', 'sort' => 50 ],
            [ 'code' => 'logistics',    'name' => 'اداری و پشتیبانی',       'pool' => 'logistics', 'weight' => 60,  'roles' => [ 'admin', 'coord' ],                 'driver' => 'weight', 'sort' => 60 ],
            [ 'code' => 'tech',         'name' => 'فناوری و GIS',           'pool' => 'oversight', 'weight' => 100, 'roles' => [ 'tech', 'admin' ],                  'driver' => 'weight', 'sort' => 70 ],
            [ 'code' => 'legal',        'name' => 'حقوقی و نظارت',          'pool' => 'advisory',  'weight' => 70,  'roles' => [ 'legal', 'inspection' ],            'driver' => 'weight', 'sort' => 80 ],
            [ 'code' => 'committees',   'name' => 'کمیته اقشار',            'pool' => 'field',     'weight' => 55,  'roles' => [ 'committees', 'supporter' ],        'driver' => 'weight', 'sort' => 90 ],
            [ 'code' => 'finance',      'name' => 'مالی و شفافیت بودجه',   'pool' => 'advisory',  'weight' => 50,  'roles' => [ 'finance', 'admin' ],               'driver' => 'share',  'sort' => 100 ],
        ];
    }

    public static function role_labels(): array {
        return [
            'media'         => 'رسانه',
            'spokesperson'  => 'سخنگو',
            'distribution'  => 'توزیع',
            'goods'         => 'اقلام',
            'warehouse'     => 'انبار',
            'supporter'     => 'هوادار',
            'field'         => 'میدانی',
            'counties'      => 'شهرستان',
            'planning'      => 'برنامه‌ریزی',
            'admin'         => 'اداری',
            'coord'         => 'هماهنگی',
            'tech'          => 'فناوری',
            'legal'         => 'حقوقی',
            'inspection'    => 'بازرسی',
            'committees'    => 'اقشار',
            'finance'       => 'مالی',
            'hq'            => 'ستاد مرکزی',
        ];
    }

    /** نگاشت نقش ACL دپارتمان به نقش ABC */
    public static function map_dept_role( string $slug ): string {
        $map = [
            'election_dept_media'         => 'media',
            'election_dept_spokesperson'  => 'spokesperson',
            'election_dept_distribution'  => 'distribution',
            'election_dept_goods'         => 'goods',
            'election_dept_planning'      => 'planning',
            'election_dept_admin'         => 'admin',
            'election_dept_coordination'  => 'coord',
            'election_dept_legal'         => 'legal',
            'election_dept_inspection'    => 'inspection',
            'election_dept_financial'     => 'finance',
            'election_dept_counties'      => 'counties',
            'election_dept_committees'    => 'committees',
            'election_dept_virtual'       => 'tech',
            'election_dept_thinktank'     => 'planning',
            'election_dept_advisory'      => 'legal',
            'election_admin'              => 'admin',
            'election_supporter'          => 'supporter',
        ];
        $k = sanitize_key( $slug );
        if ( isset( $map[ $k ] ) ) return $map[ $k ];
        if ( str_starts_with( $k, 'election_supporter' ) || str_contains( $k, 'supporter' ) ) {
            return 'supporter';
        }
        return 'hq';
    }

    public static function ensure_tables(): void {
        global $wpdb;
        $pre = $wpdb->prefix;
        $charset = 'DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci';

        $wpdb->query( "CREATE TABLE IF NOT EXISTS {$pre}election_acc_campaigns (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            campaign_key VARCHAR(60) NOT NULL,
            title VARCHAR(200) NOT NULL,
            phase VARCHAR(30) NOT NULL DEFAULT 'campaign',
            status VARCHAR(20) NOT NULL DEFAULT 'active',
            budget DECIMAL(18,2) NOT NULL DEFAULT 0,
            description TEXT NULL,
            created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (id), UNIQUE KEY campaign_key (campaign_key), KEY status (status)
        ) {$charset}" );

        $wpdb->query( "CREATE TABLE IF NOT EXISTS {$pre}election_acc_activities (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            code VARCHAR(40) NOT NULL,
            name VARCHAR(160) NOT NULL,
            pool VARCHAR(30) NOT NULL DEFAULT 'field',
            weight DECIMAL(8,2) NOT NULL DEFAULT 0,
            roles_json JSON NULL,
            driver VARCHAR(20) NOT NULL DEFAULT 'weight',
            sort_order INT NOT NULL DEFAULT 0,
            is_active TINYINT(1) NOT NULL DEFAULT 1,
            updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (id), UNIQUE KEY code (code)
        ) {$charset}" );

        $wpdb->query( "CREATE TABLE IF NOT EXISTS {$pre}election_acc_campaign_crew (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            campaign_id BIGINT UNSIGNED NOT NULL,
            user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
            role_code VARCHAR(30) NOT NULL DEFAULT 'hq',
            weight DECIMAL(8,2) NOT NULL DEFAULT 1,
            hours DECIMAL(8,2) NOT NULL DEFAULT 0,
            share_pct DECIMAL(8,2) NOT NULL DEFAULT 0,
            note VARCHAR(255) NOT NULL DEFAULT '',
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id), KEY campaign_id (campaign_id), KEY user_id (user_id)
        ) {$charset}" );

        $wpdb->query( "CREATE TABLE IF NOT EXISTS {$pre}election_acc_activity_costs (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            campaign_id BIGINT UNSIGNED NOT NULL,
            activity_code VARCHAR(40) NOT NULL,
            activity_name VARCHAR(160) NOT NULL DEFAULT '',
            pool VARCHAR(30) NOT NULL DEFAULT '',
            person_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
            person_label VARCHAR(200) NOT NULL DEFAULT '',
            role_code VARCHAR(30) NOT NULL DEFAULT '',
            driver_qty DECIMAL(12,4) NOT NULL DEFAULT 0,
            share_pct DECIMAL(8,4) NOT NULL DEFAULT 0,
            amount DECIMAL(15,2) NOT NULL DEFAULT 0,
            budget_share DECIMAL(15,2) NOT NULL DEFAULT 0,
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id), KEY campaign_id (campaign_id), KEY person (person_id), KEY activity (activity_code)
        ) {$charset}" );

        $n = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$pre}election_acc_activities" );
        if ( $n === 0 ) {
            foreach ( self::default_activities() as $a ) {
                $wpdb->insert( "{$pre}election_acc_activities", [
                    'code' => $a['code'], 'name' => $a['name'], 'pool' => $a['pool'],
                    'weight' => $a['weight'], 'roles_json' => wp_json_encode( $a['roles'] ),
                    'driver' => $a['driver'], 'sort_order' => $a['sort'], 'is_active' => 1,
                ] );
            }
        }

        self::ensure_default_campaign();
    }

    /** کمپین پیش‌فرض از جمع بودجه ستاد */
    public static function ensure_default_campaign(): ?array {
        global $wpdb;
        $t = $wpdb->prefix . 'election_acc_campaigns';
        $existing = $wpdb->get_row(
            "SELECT * FROM {$t} WHERE status = 'active' ORDER BY id ASC LIMIT 1",
            ARRAY_A
        );
        if ( $existing ) {
            $budget = self::_budget_from_election();
            if ( $budget > 0 && (float) $existing['budget'] <= 0 ) {
                $wpdb->update( $t, [ 'budget' => $budget ], [ 'id' => (int) $existing['id'] ] );
                $existing['budget'] = $budget;
            }
            return $existing;
        }

        $budget = self::_budget_from_election();
        if ( $budget <= 0 ) {
            $budget = 2_500_000_000; // برآورد نمایشی
        }
        $key = 'ELC-' . date( 'Y' );
        $wpdb->insert( $t, [
            'campaign_key' => $key,
            'title'        => 'ستاد مرکزی انتخابات — کمپین اصلی',
            'phase'        => 'campaign',
            'status'       => 'active',
            'budget'       => $budget,
            'description'  => 'کمپین پیش‌فرض برای گراف بهای تمام‌شده فعالیت',
            'created_by'   => 1,
        ] );
        $id = (int) $wpdb->insert_id;
        return $id ? self::get_campaign( $id ) : null;
    }

    private static function _budget_from_election(): float {
        global $wpdb;
        $bt = $wpdb->prefix . 'election_budget';
        if ( $wpdb->get_var( "SHOW TABLES LIKE '{$bt}'" ) !== $bt ) {
            return 0.0;
        }
        return (float) $wpdb->get_var( "SELECT COALESCE(SUM(planned_amount),0) FROM {$bt}" );
    }

    public static function list_campaigns(): array {
        global $wpdb;
        self::ensure_tables();
        return $wpdb->get_results(
            "SELECT * FROM {$wpdb->prefix}election_acc_campaigns
             WHERE status IN ('active','completed','draft')
             ORDER BY updated_at DESC LIMIT 40",
            ARRAY_A
        ) ?: [];
    }

    public static function list_activities( bool $active_only = true ): array {
        global $wpdb;
        self::ensure_tables();
        $t = $wpdb->prefix . 'election_acc_activities';
        $sql = "SELECT * FROM {$t}" . ( $active_only ? ' WHERE is_active=1' : '' ) . ' ORDER BY sort_order, id';
        $rows = $wpdb->get_results( $sql, ARRAY_A ) ?: [];
        return array_map( static function( $r ) {
            return [
                'id' => (int) $r['id'], 'code' => $r['code'], 'name' => $r['name'],
                'pool' => $r['pool'], 'weight' => (float) $r['weight'],
                'roles' => json_decode( $r['roles_json'] ?? '[]', true ) ?: [],
                'driver' => $r['driver'], 'sort' => (int) $r['sort_order'],
                'is_active' => (int) $r['is_active'] === 1,
            ];
        }, $rows );
    }

    public static function get_campaign( int $campaign_id ): ?array {
        global $wpdb;
        $row = $wpdb->get_row( $wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}election_acc_campaigns WHERE id = %d", $campaign_id
        ), ARRAY_A );
        return $row ?: null;
    }

    public static function get_crew( int $campaign_id ): array {
        global $wpdb;
        self::ensure_tables();
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT c.*, u.display_name FROM {$wpdb->prefix}election_acc_campaign_crew c
             LEFT JOIN {$wpdb->users} u ON u.ID = c.user_id
             WHERE c.campaign_id = %d ORDER BY c.id", $campaign_id
        ), ARRAY_A ) ?: [];
        if ( $rows ) {
            return array_map( static function( $r ) {
                return [
                    'id' => (int) $r['id'], 'campaign_id' => (int) $r['campaign_id'],
                    'user_id' => (int) $r['user_id'],
                    'name' => $r['display_name'] ?: ( $r['note'] ?: ( $r['user_id'] ? 'کاربر #' . $r['user_id'] : 'ستاد' ) ),
                    'role' => $r['role_code'],
                    'weight' => (float) $r['weight'],
                    'hours' => (float) $r['hours'],
                    'share_pct' => (float) $r['share_pct'],
                    'note' => $r['note'] ?? '',
                ];
            }, $rows );
        }
        return self::_infer_crew( $campaign_id );
    }

    private static function _infer_crew( int $campaign_id ): array {
        global $wpdb;
        $crew = [];
        $seen = [];

        $roles = $wpdb->get_results(
            "SELECT u.ID AS user_id, u.display_name, r.slug AS role_slug
             FROM {$wpdb->prefix}acl_user_roles ur
             INNER JOIN {$wpdb->prefix}acl_roles r ON r.id = ur.role_id
             INNER JOIN {$wpdb->users} u ON u.ID = ur.user_id
             WHERE r.slug LIKE 'election_%'
             ORDER BY r.slug, u.ID
             LIMIT 80",
            ARRAY_A
        ) ?: [];

        foreach ( $roles as $row ) {
            $uid = (int) $row['user_id'];
            $role = self::map_dept_role( (string) $row['role_slug'] );
            $key = $uid . ':' . $role;
            if ( isset( $seen[ $key ] ) ) continue;
            $seen[ $key ] = true;
            $hours = in_array( $role, [ 'supporter', 'field', 'distribution', 'goods', 'warehouse' ], true ) ? 40 : 16;
            $share = $role === 'finance' ? 8 : ( $role === 'admin' ? 12 : 5 );
            $crew[] = [
                'user_id' => $uid,
                'name' => $row['display_name'] ?: ( 'کاربر #' . $uid ),
                'role' => $role,
                'weight' => max( 0.5, $share / 5 ),
                'hours' => $hours,
                'share_pct' => $share,
                'note' => 'از نقش‌های ستاد',
                'inferred' => true,
            ];
        }

        if ( empty( $crew ) ) {
            $crew = [
                [ 'user_id' => 0, 'name' => 'مدیر ستاد', 'role' => 'admin', 'weight' => 3, 'hours' => 40, 'share_pct' => 15, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'رسانه و تبلیغات', 'role' => 'media', 'weight' => 2.5, 'hours' => 48, 'share_pct' => 18, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'عملیات میدانی', 'role' => 'field', 'weight' => 3, 'hours' => 80, 'share_pct' => 25, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'توزیع اقلام', 'role' => 'distribution', 'weight' => 2, 'hours' => 56, 'share_pct' => 12, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'حوزه مالی', 'role' => 'finance', 'weight' => 1.5, 'hours' => 24, 'share_pct' => 10, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'هواداران نمونه', 'role' => 'supporter', 'weight' => 2, 'hours' => 60, 'share_pct' => 20, 'note' => 'خودکار', 'inferred' => true ],
            ];
        }
        return $crew;
    }

    public static function save_crew( int $campaign_id, array $members ): array {
        global $wpdb;
        self::ensure_tables();
        $t = $wpdb->prefix . 'election_acc_campaign_crew';
        $wpdb->delete( $t, [ 'campaign_id' => $campaign_id ] );
        $n = 0;
        foreach ( $members as $m ) {
            $wpdb->insert( $t, [
                'campaign_id' => $campaign_id,
                'user_id'     => (int) ( $m['user_id'] ?? 0 ),
                'role_code'   => sanitize_key( (string) ( $m['role'] ?? 'hq' ) ),
                'weight'      => max( 0.01, (float) ( $m['weight'] ?? 1 ) ),
                'hours'       => max( 0, (float) ( $m['hours'] ?? 0 ) ),
                'share_pct'   => max( 0, (float) ( $m['share_pct'] ?? $m['share_percent'] ?? 0 ) ),
                'note'        => sanitize_text_field( (string) ( $m['note'] ?? '' ) ),
            ] );
            $n++;
        }
        return [ 'success' => true, 'saved' => $n, 'data' => self::get_crew( $campaign_id ) ];
    }

    public static function pools_for_campaign( array $campaign ): array {
        $budget = (float) ( $campaign['budget'] ?? 0 );
        if ( $budget <= 0 ) {
            $budget = self::_budget_from_election();
        }
        if ( $budget <= 0 ) {
            $budget = 2_500_000_000;
        }
        return [
            'outreach'  => round( $budget * 0.28, 2 ),
            'field'     => round( $budget * 0.30, 2 ),
            'logistics' => round( $budget * 0.16, 2 ),
            'staff'     => round( $budget * 0.12, 2 ),
            'oversight' => round( $budget * 0.06, 2 ),
            'advisory'  => round( $budget * 0.08, 2 ),
            'budget'    => $budget,
        ];
    }

    public static function allocate( array $campaign ): array {
        $campaign_id = (int) ( $campaign['id'] ?? 0 );
        $pools = self::pools_for_campaign( $campaign );
        $crew = self::get_crew( $campaign_id );
        $activities = self::list_activities( true );
        $lines = [];
        $person_totals = [];

        foreach ( $activities as $act ) {
            $pool_amt = (float) ( $pools[ $act['pool'] ] ?? 0 );
            if ( $pool_amt <= 0 ) continue;

            $pool_w = 0.0;
            foreach ( $activities as $a2 ) {
                if ( $a2['pool'] === $act['pool'] ) $pool_w += (float) $a2['weight'];
            }
            if ( $pool_w <= 0 ) continue;
            $activity_amount = round( $pool_amt * ( (float) $act['weight'] / $pool_w ), 2 );
            if ( $activity_amount <= 0 ) continue;

            $eligible = array_values( array_filter( $crew, static function( $m ) use ( $act ) {
                return in_array( $m['role'], $act['roles'], true );
            } ) );

            if ( empty( $eligible ) ) {
                $lines[] = self::_line( $act, 0, 'تخصیص‌نشده / ستاد', 'hq', 1, 100, $activity_amount );
                $key = 'role:hq';
                if ( ! isset( $person_totals[ $key ] ) ) {
                    $person_totals[ $key ] = [
                        'person_id' => 0, 'name' => 'تخصیص‌نشده / ستاد', 'role' => 'hq',
                        'amount' => 0, 'by_activity' => [],
                    ];
                }
                $person_totals[ $key ]['amount'] += $activity_amount;
                $person_totals[ $key ]['by_activity'][ $act['code'] ] =
                    ( $person_totals[ $key ]['by_activity'][ $act['code'] ] ?? 0 ) + $activity_amount;
                continue;
            }

            $den = 0.0;
            foreach ( $eligible as $m ) {
                $den += self::_driver_qty( $act['driver'], $m );
            }
            $allocated = 0.0;
            $last = count( $eligible ) - 1;
            foreach ( $eligible as $i => $m ) {
                $qty = self::_driver_qty( $act['driver'], $m );
                $pct = round( 100 * $qty / $den, 4 );
                $amt = ( $i === $last )
                    ? round( $activity_amount - $allocated, 2 )
                    : round( $activity_amount * $qty / $den, 2 );
                $allocated += $amt;
                $pid = (int) ( $m['user_id'] ?? 0 );
                $label = $m['name'] ?? ( $pid ? "کاربر #{$pid}" : 'ستاد' );
                $lines[] = self::_line( $act, $pid, $label, $m['role'] ?? '', $qty, $pct, $amt );

                $key = $pid > 0 ? (string) $pid : ( 'role:' . ( $m['role'] ?? 'hq' ) );
                if ( ! isset( $person_totals[ $key ] ) ) {
                    $person_totals[ $key ] = [
                        'person_id' => $pid,
                        'name' => $label,
                        'role' => $m['role'] ?? '',
                        'amount' => 0,
                        'by_activity' => [],
                    ];
                }
                $person_totals[ $key ]['amount'] += $amt;
                $person_totals[ $key ]['by_activity'][ $act['code'] ] =
                    ( $person_totals[ $key ]['by_activity'][ $act['code'] ] ?? 0 ) + $amt;
            }
        }

        $budget = (float) ( $pools['budget'] ?? 0 );
        $psum = array_sum( array_column( $person_totals, 'amount' ) );
        if ( $budget > 0 && $psum > 0 ) {
            foreach ( $person_totals as &$pt ) {
                $pt['budget_share'] = round( $budget * ( $pt['amount'] / $psum ), 2 );
            }
            unset( $pt );
        }

        return [
            'campaign_id' => $campaign_id,
            'campaign_key' => $campaign['campaign_key'] ?? '',
            'title' => $campaign['title'] ?? '',
            'status' => $campaign['status'] ?? '',
            'pools' => $pools,
            'crew' => $crew,
            'activities' => $activities,
            'lines' => $lines,
            'person_shares' => array_values( $person_totals ),
            'totals' => [
                'activity_cost' => array_sum( array_column( $lines, 'amount' ) ),
                'budget' => $budget,
                'coverage' => $budget > 0
                    ? round( 100 * array_sum( array_column( $lines, 'amount' ) ) / $budget, 2 )
                    : 0,
            ],
            'role_labels' => self::role_labels(),
        ];
    }

    private static function _driver_qty( string $driver, array $m ): float {
        if ( $driver === 'hours' ) return max( 0.01, (float) ( $m['hours'] ?? 0 ) );
        if ( $driver === 'share' ) {
            $s = (float) ( $m['share_pct'] ?? 0 );
            return max( 0.01, $s > 0 ? $s : (float) ( $m['weight'] ?? 1 ) );
        }
        return max( 0.01, (float) ( $m['weight'] ?? 1 ) );
    }

    private static function _line( array $act, int $pid, string $label, string $role, float $qty, float $pct, float $amt ): array {
        return [
            'activity_code' => $act['code'],
            'activity_name' => $act['name'],
            'pool' => $act['pool'],
            'person_id' => $pid,
            'person_label' => $label,
            'role_code' => $role,
            'driver_qty' => $qty,
            'share_pct' => $pct,
            'amount' => $amt,
            'budget_share' => 0,
        ];
    }

    public static function persist( int $campaign_id, array $allocation ): void {
        global $wpdb;
        self::ensure_tables();
        $t = $wpdb->prefix . 'election_acc_activity_costs';
        $wpdb->delete( $t, [ 'campaign_id' => $campaign_id ] );
        foreach ( $allocation['lines'] ?? [] as $ln ) {
            $wpdb->insert( $t, [
                'campaign_id' => $campaign_id,
                'activity_code' => $ln['activity_code'],
                'activity_name' => $ln['activity_name'],
                'pool' => $ln['pool'],
                'person_id' => (int) $ln['person_id'],
                'person_label' => $ln['person_label'],
                'role_code' => $ln['role_code'],
                'driver_qty' => $ln['driver_qty'],
                'share_pct' => $ln['share_pct'],
                'amount' => $ln['amount'],
                'budget_share' => $ln['budget_share'] ?? 0,
            ] );
        }
    }

    public static function cost_graph( array $filters = [] ): array {
        global $wpdb;
        self::ensure_tables();
        $pre = $wpdb->prefix;
        $campaign_id = (int) ( $filters['campaign_id'] ?? 0 );

        if ( $campaign_id > 0 ) {
            $campaign = self::get_campaign( $campaign_id );
            if ( ! $campaign ) return [ 'nodes' => [], 'edges' => [], 'person_shares' => [], 'campaigns' => [] ];
            $alloc = self::allocate( $campaign );
            return self::_graph( [ $alloc ], $campaign );
        }

        $campaigns = self::list_campaigns();
        $allocs = [];
        foreach ( $campaigns as $c ) {
            $saved = $wpdb->get_results( $wpdb->prepare(
                "SELECT * FROM {$pre}election_acc_activity_costs WHERE campaign_id=%d", (int) $c['id']
            ), ARRAY_A );
            if ( $saved ) {
                $lines = [];
                $ps = [];
                foreach ( $saved as $s ) {
                    $lines[] = [
                        'activity_code' => $s['activity_code'], 'activity_name' => $s['activity_name'],
                        'pool' => $s['pool'], 'person_id' => (int) $s['person_id'],
                        'person_label' => $s['person_label'], 'role_code' => $s['role_code'],
                        'amount' => (float) $s['amount'], 'share_pct' => (float) $s['share_pct'],
                    ];
                    $pid = (int) $s['person_id'];
                    $key = $pid ?: ( 'r:' . $s['role_code'] );
                    if ( ! isset( $ps[ $key ] ) ) {
                        $ps[ $key ] = [ 'person_id' => $pid, 'name' => $s['person_label'], 'amount' => 0 ];
                    }
                    $ps[ $key ]['amount'] += (float) $s['amount'];
                }
                $allocs[] = [
                    'campaign_id' => (int) $c['id'],
                    'campaign_key' => $c['campaign_key'] ?? '',
                    'title' => $c['title'] ?? '',
                    'lines' => $lines,
                    'person_shares' => array_values( $ps ),
                    'totals' => [
                        'activity_cost' => array_sum( array_column( $lines, 'amount' ) ),
                        'budget' => (float) ( $c['budget'] ?? 0 ),
                    ],
                ];
            } else {
                $allocs[] = self::allocate( $c );
            }
        }
        return self::_graph( $allocs );
    }

    private static function _graph( array $allocs, ?array $single = null ): array {
        $nodes = []; $edges = []; $persons = []; $campaigns = [];
        $add = static function( $id, $type, $label, $val = 0 ) use ( &$nodes ) {
            if ( ! isset( $nodes[ $id ] ) ) {
                $nodes[ $id ] = [ 'id' => $id, 'type' => $type, 'label' => $label, 'value' => 0.0 ];
            }
            $nodes[ $id ]['value'] += $val;
        };

        foreach ( $allocs as $alloc ) {
            $cid = 'campaign:' . (int) $alloc['campaign_id'];
            $title = $alloc['title'] ?? ( $single['title'] ?? ( '#' . $alloc['campaign_id'] ) );
            $key = $alloc['campaign_key'] ?? ( $single['campaign_key'] ?? '' );
            $budget = (float) ( $alloc['totals']['budget'] ?? 0 );
            $add( $cid, 'campaign', ( $key ? $key . ' — ' : '' ) . $title, $budget );
            $campaigns[] = [
                'campaign_id' => (int) $alloc['campaign_id'],
                'campaign_key' => $key,
                'title' => $title,
                'budget' => $budget,
                'cost' => (float) ( $alloc['totals']['activity_cost'] ?? 0 ),
                'persons' => count( $alloc['person_shares'] ?? [] ),
            ];

            foreach ( $alloc['lines'] ?? [] as $ln ) {
                $aid = 'act:' . $ln['activity_code'];
                $add( $aid, 'activity', $ln['activity_name'], (float) $ln['amount'] );
                $ek1 = $cid . '>' . $aid;
                if ( ! isset( $edges[ $ek1 ] ) ) {
                    $edges[ $ek1 ] = [ 'from' => $cid, 'to' => $aid, 'amount' => 0, 'label' => $ln['pool'] ];
                }
                $edges[ $ek1 ]['amount'] += (float) $ln['amount'];

                $uid = (int) ( $ln['person_id'] ?? 0 );
                $nid = $uid > 0 ? ( 'person:' . $uid ) : ( 'role:' . ( $ln['role_code'] ?: 'hq' ) );
                $add( $nid, 'person', $ln['person_label'], (float) $ln['amount'] );
                $ek2 = $aid . '>' . $nid;
                if ( ! isset( $edges[ $ek2 ] ) ) {
                    $edges[ $ek2 ] = [ 'from' => $aid, 'to' => $nid, 'amount' => 0, 'label' => ( $ln['share_pct'] ?? '' ) . '%' ];
                }
                $edges[ $ek2 ]['amount'] += (float) $ln['amount'];

                if ( ! isset( $persons[ $nid ] ) ) {
                    $persons[ $nid ] = [
                        'person_id' => $uid, 'name' => $ln['person_label'],
                        'role' => $ln['role_code'] ?? '', 'amount' => 0,
                    ];
                }
                $persons[ $nid ]['amount'] += (float) $ln['amount'];
            }
        }

        return [
            'nodes' => array_values( $nodes ),
            'edges' => array_values( $edges ),
            'person_shares' => array_values( $persons ),
            'campaigns' => $campaigns,
            'role_labels' => self::role_labels(),
        ];
    }
}
