<?php
/**
 * education-activity-cost.php — بهای تمام‌شده فعالیت‌های جلسه آموزشی
 * جلسه/کلاس ↔ فعالیت (تدریس · ارزیابی · مربی‌گری · محتوا · فضا · پلتفرم)
 * ↔ نقش‌ها (معلم، مربی، کادر مؤسسه، پلتفرم)
 */
if ( ! defined( 'ABSPATH' ) ) exit;

class Edu_Activity_Cost {

    public static function default_activities(): array {
        return [
            [ 'code' => 'instruction', 'name' => 'تدریس و ارائه',           'pool' => 'delivery',  'weight' => 55, 'roles' => [ 'teacher', 'mentor' ],           'driver' => 'hours',  'sort' => 10 ],
            [ 'code' => 'assessment',  'name' => 'آزمون و ارزیابی',         'pool' => 'delivery',  'weight' => 25, 'roles' => [ 'teacher', 'mentor' ],           'driver' => 'weight', 'sort' => 20 ],
            [ 'code' => 'mentoring',   'name' => 'مربی‌گری و همراهی',       'pool' => 'mentoring', 'weight' => 70, 'roles' => [ 'mentor' ],                     'driver' => 'hours',  'sort' => 30 ],
            [ 'code' => 'counseling',  'name' => 'مشاوره مسیر تحصیلی',      'pool' => 'mentoring', 'weight' => 30, 'roles' => [ 'mentor', 'staff' ],            'driver' => 'weight', 'sort' => 40 ],
            [ 'code' => 'materials',   'name' => 'محتوا و منابع آموزشی',    'pool' => 'materials', 'weight' => 100,'roles' => [ 'teacher', 'staff', 'platform' ],'driver' => 'weight', 'sort' => 50 ],
            [ 'code' => 'venue',       'name' => 'فضا و کلاس',              'pool' => 'venue',     'weight' => 70, 'roles' => [ 'staff', 'org' ],               'driver' => 'share',  'sort' => 60 ],
            [ 'code' => 'transport',   'name' => 'ایاب و ذهاب',             'pool' => 'venue',     'weight' => 30, 'roles' => [ 'mentor', 'teacher' ],          'driver' => 'hours',  'sort' => 70 ],
            [ 'code' => 'admin',       'name' => 'هماهنگی مؤسسه',           'pool' => 'overhead',  'weight' => 55, 'roles' => [ 'staff', 'org' ],               'driver' => 'weight', 'sort' => 80 ],
            [ 'code' => 'platform',    'name' => 'سربار پلتفرم EduBrain',   'pool' => 'overhead',  'weight' => 45, 'roles' => [ 'platform' ],                   'driver' => 'weight', 'sort' => 90 ],
        ];
    }

    public static function role_labels(): array {
        return [
            'teacher'  => 'معلم',
            'mentor'   => 'مربی',
            'staff'    => 'کادر مؤسسه',
            'org'      => 'مؤسسه / فضا',
            'platform' => 'پلتفرم',
            'learner'  => 'دانش‌آموز (سهم نمایشی)',
        ];
    }

    public static function map_educator_role( string $educator_type ): string {
        $k = sanitize_key( $educator_type );
        if ( $k === 'teacher' ) return 'teacher';
        if ( $k === 'mentor' ) return 'mentor';
        return 'mentor';
    }

    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}edu_abc_activities (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            code VARCHAR(40) NOT NULL,
            name VARCHAR(160) NOT NULL,
            pool VARCHAR(30) NOT NULL DEFAULT 'delivery',
            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}edu_abc_session_crew (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            session_id BIGINT UNSIGNED NOT NULL,
            user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
            role_code VARCHAR(30) NOT NULL DEFAULT 'mentor',
            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 session_id (session_id), KEY user_id (user_id)
        ) {$charset}" );

        $wpdb->query( "CREATE TABLE IF NOT EXISTS {$pre}edu_abc_activity_costs (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            session_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 session_id (session_id), KEY person (person_id), KEY activity (activity_code)
        ) {$charset}" );

        $n = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$pre}edu_abc_activities" );
        if ( $n === 0 ) {
            foreach ( self::default_activities() as $a ) {
                $wpdb->insert( "{$pre}edu_abc_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,
                ] );
            }
        }
    }

    public static function list_activities( bool $active_only = true ): array {
        global $wpdb;
        self::ensure_tables();
        $t = $wpdb->prefix . 'edu_abc_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_session( int $session_id ): ?array {
        global $wpdb;
        $row = $wpdb->get_row( $wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}edu_sessions WHERE id = %d", $session_id
        ), ARRAY_A );
        return $row ?: null;
    }

    public static function list_sessions( int $limit = 40 ): array {
        global $wpdb;
        $limit = max( 1, min( 100, $limit ) );
        return $wpdb->get_results(
            "SELECT id, session_type, domain, track, status, amount_total, duration_min, educator_id, org_id, scheduled_at
             FROM {$wpdb->prefix}edu_sessions
             ORDER BY id DESC LIMIT {$limit}",
            ARRAY_A
        ) ?: [];
    }

    public static function get_crew( int $session_id ): array {
        global $wpdb;
        self::ensure_tables();
        $rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT c.*, u.display_name FROM {$wpdb->prefix}edu_abc_session_crew c
             LEFT JOIN {$wpdb->users} u ON u.ID = c.user_id
             WHERE c.session_id = %d ORDER BY c.id", $session_id
        ), ARRAY_A ) ?: [];
        if ( $rows ) {
            return array_map( static function( $r ) {
                return [
                    'id' => (int) $r['id'], 'session_id' => (int) $r['session_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( $session_id );
    }

    private static function _infer_crew( int $session_id ): array {
        global $wpdb;
        $session = self::get_session( $session_id );
        if ( ! $session ) return [];

        $hours = max( 0.5, ( (int) ( $session['duration_min'] ?? 90 ) ) / 60 );
        $crew = [];

        $educator_id = (int) ( $session['educator_id'] ?? 0 );
        if ( $educator_id > 0 ) {
            $ed = $wpdb->get_row( $wpdb->prepare(
                "SELECT e.*, u.display_name, u.ID AS uid FROM {$wpdb->prefix}edu_educators e
                 LEFT JOIN {$wpdb->users} u ON u.ID = e.user_id
                 WHERE e.id = %d", $educator_id
            ), ARRAY_A );
            if ( $ed ) {
                $role = self::map_educator_role( (string) ( $ed['educator_type'] ?? 'mentor' ) );
                $crew[] = [
                    'user_id' => (int) ( $ed['uid'] ?? $ed['user_id'] ?? 0 ),
                    'name' => $ed['display_name'] ?: ( 'آموزگار #' . $educator_id ),
                    'role' => $role,
                    'weight' => 4,
                    'hours' => $hours,
                    'share_pct' => 62,
                    'note' => 'از جلسه آموزشی',
                    'inferred' => true,
                ];
            }
        }

        $org_id = (int) ( $session['org_id'] ?? 0 );
        if ( $org_id > 0 ) {
            $org = $wpdb->get_row( $wpdb->prepare(
                "SELECT id, name FROM {$wpdb->prefix}edu_organizations WHERE id = %d", $org_id
            ), ARRAY_A );
            $crew[] = [
                'user_id' => 0,
                'name' => $org['name'] ?? ( 'مؤسسه #' . $org_id ),
                'role' => 'org',
                'weight' => 2,
                'hours' => 0,
                'share_pct' => 12,
                'note' => 'فضا / مؤسسه',
                'inferred' => true,
            ];
            $crew[] = [
                'user_id' => 0,
                'name' => 'کادر اجرایی مؤسسه',
                'role' => 'staff',
                'weight' => 1.5,
                'hours' => max( 1, $hours * 0.3 ),
                'share_pct' => 8,
                'note' => 'خودکار',
                'inferred' => true,
            ];
        }

        $crew[] = [
            'user_id' => 0,
            'name' => 'EduBrain Platform',
            'role' => 'platform',
            'weight' => 1,
            'hours' => 0,
            'share_pct' => 8,
            'note' => 'سربار پلتفرم',
            'inferred' => true,
        ];

        if ( count( $crew ) < 2 ) {
            $crew = [
                [ 'user_id' => 0, 'name' => 'مربی جلسه', 'role' => 'mentor', 'weight' => 4, 'hours' => $hours, 'share_pct' => 62, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'مؤسسه', 'role' => 'org', 'weight' => 2, 'hours' => 0, 'share_pct' => 12, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'کادر اجرایی', 'role' => 'staff', 'weight' => 1.5, 'hours' => 1, 'share_pct' => 8, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'EduBrain Platform', 'role' => 'platform', 'weight' => 1, 'hours' => 0, 'share_pct' => 8, 'note' => 'خودکار', 'inferred' => true ],
            ];
        }

        return $crew;
    }

    public static function save_crew( int $session_id, array $members ): array {
        global $wpdb;
        self::ensure_tables();
        $t = $wpdb->prefix . 'edu_abc_session_crew';
        $wpdb->delete( $t, [ 'session_id' => $session_id ] );
        $n = 0;
        foreach ( $members as $m ) {
            $wpdb->insert( $t, [
                'session_id' => $session_id,
                'user_id'    => (int) ( $m['user_id'] ?? 0 ),
                'role_code'  => sanitize_key( (string) ( $m['role'] ?? 'mentor' ) ),
                '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( $session_id ) ];
    }

    /** استخرهای هزینه از quote / مبلغ جلسه */
    public static function pools_for_session( array $session ): array {
        $quote = json_decode( $session['quote_json'] ?? '{}', true ) ?: [];
        $cost  = json_decode( $session['cost_json'] ?? '{}', true ) ?: [];
        $total = (float) ( $session['amount_total'] ?? 0 );
        if ( $total <= 0 ) {
            $total = (float) ( $quote['total'] ?? 0 );
        }
        if ( $total <= 0 ) {
            $dur = max( 0.5, ( (int) ( $session['duration_min'] ?? 90 ) ) / 60 );
            $total = round( 800000 * $dur * 1.35, 2 );
        }

        $by_code = [];
        foreach ( array_merge( $quote['lines'] ?? [], is_array( $cost['lines'] ?? null ) ? $cost['lines'] : [] ) as $ln ) {
            $code = sanitize_key( (string) ( $ln['key'] ?? $ln['code'] ?? '' ) );
            if ( $code ) {
                $by_code[ $code ] = (float) ( $ln['amount'] ?? 0 );
            }
        }
        foreach ( [ 'mentor_fee', 'venue_fee', 'transport', 'materials', 'overhead', 'platform' ] as $k ) {
            if ( ! isset( $by_code[ $k ] ) && isset( $cost[ $k ] ) ) {
                $by_code[ $k ] = (float) $cost[ $k ];
            }
        }

        $mentor = (float) ( $by_code['mentor_fee'] ?? $cost['mentor_fee'] ?? $total * 0.55 );
        $venue  = (float) ( $by_code['venue_fee'] ?? $cost['venue_fee'] ?? $total * 0.12 );
        $transport = (float) ( $by_code['transport'] ?? $cost['transport'] ?? $total * 0.06 );
        $materials = (float) ( $by_code['materials'] ?? $cost['materials'] ?? $total * 0.05 );
        $overhead  = (float) ( $by_code['overhead'] ?? $cost['overhead'] ?? $total * 0.05 );
        $platform  = (float) ( $by_code['platform'] ?? $cost['platform'] ?? $total * 0.08 );

        $delivery  = round( $mentor * 0.72, 2 );
        $mentoring = round( $mentor * 0.28, 2 );
        $venue_pool = round( $venue + $transport, 2 );
        $oh_pool = round( $overhead + $platform, 2 );

        return [
            'delivery'  => $delivery,
            'mentoring' => $mentoring,
            'materials' => round( $materials, 2 ),
            'venue'     => $venue_pool,
            'overhead'  => $oh_pool,
            'budget'    => round( $total, 2 ),
        ];
    }

    public static function allocate( array $session ): array {
        $session_id = (int) ( $session['id'] ?? 0 );
        $pools = self::pools_for_session( $session );
        $crew = self::get_crew( $session_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 ) && in_array( 'platform', $act['roles'], true ) ) {
                $lines[] = self::_line( $act, 0, 'EduBrain Platform', 'platform', 1, 100, $activity_amount );
                continue;
            }
            if ( empty( $eligible ) ) {
                $lines[] = self::_line( $act, 0, 'تخصیص‌نشده', 'platform', 1, 100, $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'] ?? 'platform' ) );
                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 );
        }

        $title = trim( ( $session['session_type'] ?? 'session' ) . ' · ' . ( $session['domain'] ?? '' ) );

        return [
            'session_id' => $session_id,
            'title' => $title,
            'status' => $session['status'] ?? '',
            'session_type' => $session['session_type'] ?? '',
            'domain' => $session['domain'] ?? '',
            '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 $session_id, array $allocation ): void {
        global $wpdb;
        self::ensure_tables();
        $t = $wpdb->prefix . 'edu_abc_activity_costs';
        $wpdb->delete( $t, [ 'session_id' => $session_id ] );
        foreach ( $allocation['lines'] ?? [] as $ln ) {
            $wpdb->insert( $t, [
                'session_id' => $session_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;
        $session_id = (int) ( $filters['session_id'] ?? 0 );

        if ( $session_id > 0 ) {
            $session = self::get_session( $session_id );
            if ( ! $session ) return [ 'nodes' => [], 'edges' => [], 'person_shares' => [], 'sessions' => [] ];
            $alloc = self::allocate( $session );
            return self::_graph( [ $alloc ], $session );
        }

        $sessions = $wpdb->get_results(
            "SELECT * FROM {$pre}edu_sessions
             WHERE status IN ('submitted','matching','assigned','confirmed','in_progress','completed','posted')
             ORDER BY id DESC LIMIT 40",
            ARRAY_A
        ) ?: [];

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

    private static function _graph( array $allocs, ?array $single = null ): array {
        $nodes = []; $edges = []; $persons = []; $sessions = [];
        $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 ) {
            $sid = 'session:' . (int) $alloc['session_id'];
            $title = $alloc['title'] ?? ( $single
                ? trim( ( $single['session_type'] ?? '' ) . ' · ' . ( $single['domain'] ?? '' ) )
                : ( '#' . $alloc['session_id'] ) );
            $budget = (float) ( $alloc['totals']['budget'] ?? 0 );
            $add( $sid, 'session', '#' . (int) $alloc['session_id'] . ' — ' . $title, $budget );
            $sessions[] = [
                'session_id' => (int) $alloc['session_id'],
                '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 = $sid . '>' . $aid;
                if ( ! isset( $edges[ $ek1 ] ) ) {
                    $edges[ $ek1 ] = [ 'from' => $sid, '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'] ?: 'platform' ) );
                $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 ),
            'sessions' => $sessions,
            'role_labels' => self::role_labels(),
        ];
    }
}
