<?php
/**
 * melk-activity-cost.php — بهای تمام‌شده فعالیت‌های پروژه ساخت/مشارکت
 * پروژه ↔ فعالیت (زمین / طراحی / مجوز / سازه / تاسیسات / نازک‌کاری / نظارت / حقوقی / مالی)
 * ↔ شرکا (مالک، پیمانکار، ناظر، سرمایه‌گذار، …)
 */
if ( ! defined( 'ABSPATH' ) ) exit;

class Melk_Activity_Cost {

    public static function default_activities(): array {
        return [
            [ 'code' => 'land',        'name' => 'زمین و تملک',           'pool' => 'land',        'weight' => 100, 'roles' => [ 'owner', 'investor' ],              'driver' => 'share',  'sort' => 10 ],
            [ 'code' => 'design',      'name' => 'طراحی و معماری',        'pool' => 'soft',        'weight' => 55,  'roles' => [ 'architect', 'engineer' ],         'driver' => 'weight', 'sort' => 20 ],
            [ 'code' => 'permit',      'name' => 'مجوز و شهرداری',         'pool' => 'soft',        'weight' => 45,  'roles' => [ 'legal', 'owner', 'company' ],     'driver' => 'weight', 'sort' => 30 ],
            [ 'code' => 'structure',   'name' => 'اسکلت و سازه',           'pool' => 'hard',        'weight' => 55,  'roles' => [ 'contractor', 'engineer' ],        'driver' => 'hours',  'sort' => 40 ],
            [ 'code' => 'mep',         'name' => 'تاسیسات MEP',            'pool' => 'hard',        'weight' => 25,  'roles' => [ 'contractor', 'specialist' ],      'driver' => 'hours',  'sort' => 50 ],
            [ 'code' => 'finish',      'name' => 'نازک‌کاری و تکمیل',      'pool' => 'hard',        'weight' => 20,  'roles' => [ 'contractor' ],                   'driver' => 'hours',  'sort' => 60 ],
            [ 'code' => 'supervise',   'name' => 'نظارت و کنترل کیفیت',   'pool' => 'oversight',   'weight' => 100, 'roles' => [ 'supervisor', 'engineer' ],       'driver' => 'weight', 'sort' => 70 ],
            [ 'code' => 'legal',       'name' => 'حقوقی و قرارداد',        'pool' => 'advisory',    'weight' => 60,  'roles' => [ 'legal' ],                        'driver' => 'weight', 'sort' => 80 ],
            [ 'code' => 'finance',     'name' => 'مالی و تسهیلات',         'pool' => 'advisory',    'weight' => 40,  'roles' => [ 'finance', 'investor', 'company' ], 'driver' => 'share', 'sort' => 90 ],
            [ 'code' => 'marketing',   'name' => 'بازاریابی / فروش واحد',  'pool' => 'go_to_market','weight' => 100, 'roles' => [ 'agent', 'company' ],             'driver' => 'weight', 'sort' => 100 ],
        ];
    }

    public static function role_labels(): array {
        return [
            'owner'      => 'مالک / آورنده زمین',
            'investor'   => 'سرمایه‌گذار',
            'contractor' => 'پیمانکار',
            'architect'  => 'معمار',
            'engineer'   => 'مهندس',
            'specialist' => 'متخصص تاسیسات',
            'supervisor' => 'ناظر',
            'legal'      => 'حقوقی',
            'finance'    => 'مالی',
            'agent'      => 'مشاور املاک',
            'company'    => 'شرکت / سربار',
        ];
    }

    /** نگاشت role_type شرکت‌کننده ساخت به نقش ABC */
    public static function map_participant_role( string $role_type ): string {
        $map = [
            'owner' => 'owner', 'landowner' => 'owner', 'landlord' => 'owner',
            'investor' => 'investor', 'partner' => 'investor',
            'contractor' => 'contractor', 'builder' => 'contractor',
            'architect' => 'architect', 'arch' => 'architect',
            'engineer' => 'engineer', 'struct' => 'engineer', 'structure' => 'engineer',
            'mep' => 'specialist', 'specialist' => 'specialist', 'technician' => 'specialist',
            'supervisor' => 'supervisor', 'supervisor_qc' => 'supervisor', 'qc' => 'supervisor',
            'legal' => 'legal', 'lawyer' => 'legal',
            'finance' => 'finance', 'accountant' => 'finance',
            'agent' => 'agent', 'broker' => 'agent',
            'company' => 'company',
        ];
        $k = sanitize_key( $role_type );
        return $map[ $k ] ?? ( $k ?: 'contractor' );
    }

    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}melk_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 'hard',
            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}melk_acc_project_crew (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            project_id BIGINT UNSIGNED NOT NULL,
            user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
            role_code VARCHAR(30) NOT NULL DEFAULT 'contractor',
            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 project_id (project_id), KEY user_id (user_id)
        ) {$charset}" );

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

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

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

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

    private static function _infer_crew( int $project_id ): array {
        global $wpdb;
        $project = self::get_project( $project_id );
        if ( ! $project ) return [];

        $parts = $wpdb->get_results( $wpdb->prepare(
            "SELECT cp.*, u.display_name FROM {$wpdb->prefix}melk_construction_participants cp
             LEFT JOIN {$wpdb->users} u ON u.ID = cp.user_id
             WHERE cp.project_id = %d AND cp.status = 'active' ORDER BY cp.id",
            $project_id
        ), ARRAY_A ) ?: [];

        $crew = [];
        foreach ( $parts as $p ) {
            $role = self::map_participant_role( (string) ( $p['role_type'] ?? 'contractor' ) );
            $share = $p['share_percent'] !== null ? (float) $p['share_percent'] : 0;
            $crew[] = [
                'user_id' => (int) $p['user_id'],
                'name' => $p['display_name'] ?: ( 'شریک #' . $p['user_id'] ),
                'role' => $role,
                'weight' => max( 0.5, $share > 0 ? $share / 10 : 1 ),
                'hours' => $role === 'contractor' || $role === 'specialist' ? 40 : 8,
                'share_pct' => $share,
                'note' => 'از مشارکت‌کنندگان پروژه',
                'inferred' => true,
            ];
        }

        if ( empty( $crew ) ) {
            $crew = [
                [ 'user_id' => 0, 'name' => 'مالک پروژه', 'role' => 'owner', 'weight' => 3, 'hours' => 0, 'share_pct' => 40, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'پیمانکار اصلی', 'role' => 'contractor', 'weight' => 4, 'hours' => 120, 'share_pct' => 35, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'ناظر', 'role' => 'supervisor', 'weight' => 1, 'hours' => 24, 'share_pct' => 5, 'note' => 'خودکار', 'inferred' => true ],
                [ 'user_id' => 0, 'name' => 'سرمایه‌گذار', 'role' => 'investor', 'weight' => 2, 'hours' => 0, 'share_pct' => 20, 'note' => 'خودکار', 'inferred' => true ],
            ];
        } else {
            $has_company = false;
            foreach ( $crew as $c ) {
                if ( ( $c['role'] ?? '' ) === 'company' ) { $has_company = true; break; }
            }
            if ( ! $has_company ) {
                $crew[] = [
                    'user_id' => 0, 'name' => 'سربار شبکه', 'role' => 'company',
                    'weight' => 1, 'hours' => 0, 'share_pct' => 0, 'note' => 'خودکار', 'inferred' => true,
                ];
            }
        }
        return $crew;
    }

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

    /** استخرهای هزینه از بودجه پروژه */
    public static function pools_for_project( array $project ): array {
        $budget = (float) ( $project['budget'] ?? 0 );
        if ( $budget <= 0 ) {
            $area = (float) ( $project['area_sqm'] ?? 0 );
            $budget = $area > 0 ? $area * 25_000_000 : 5_000_000_000; // برآورد ریالی نمایشی
        }
        return [
            'land'         => round( $budget * 0.28, 2 ),
            'soft'         => round( $budget * 0.08, 2 ),
            'hard'         => round( $budget * 0.48, 2 ),
            'oversight'    => round( $budget * 0.04, 2 ),
            'advisory'     => round( $budget * 0.05, 2 ),
            'go_to_market' => round( $budget * 0.03, 2 ),
            'budget'       => $budget,
        ];
    }

    public static function allocate( array $project ): array {
        $project_id = (int) ( $project['id'] ?? 0 );
        $pools = self::pools_for_project( $project );
        $crew = self::get_crew( $project_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( 'company', $act['roles'], true ) ) {
                $lines[] = self::_line( $act, 0, 'شرکت', 'company', 1, 100, $activity_amount );
                continue;
            }
            if ( empty( $eligible ) ) {
                $lines[] = self::_line( $act, 0, 'تخصیص‌نشده', 'company', 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'] ?? 'company' ) );
                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;
            }
        }

        foreach ( $person_totals as &$pt ) {
            $pt['amount'] = round( (float) $pt['amount'], 2 );
            foreach ( $pt['by_activity'] as $k => $v ) {
                $pt['by_activity'][ $k ] = round( (float) $v, 2 );
            }
        }
        unset( $pt );

        $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 [
            'project_id' => $project_id,
            'project_key' => $project['project_key'] ?? '',
            'title' => $project['title'] ?? '',
            'status' => $project['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 $project_id, array $allocation ): void {
        global $wpdb;
        self::ensure_tables();
        $t = $wpdb->prefix . 'melk_acc_activity_costs';
        $wpdb->delete( $t, [ 'project_id' => $project_id ] );
        foreach ( $allocation['lines'] ?? [] as $ln ) {
            $wpdb->insert( $t, [
                'project_id' => $project_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;
        $project_id = (int) ( $filters['project_id'] ?? 0 );

        if ( $project_id > 0 ) {
            $project = self::get_project( $project_id );
            if ( ! $project ) return [ 'nodes' => [], 'edges' => [], 'person_shares' => [], 'projects' => [] ];
            $alloc = self::allocate( $project );
            return self::_graph( [ $alloc ], $project );
        }

        $projects = $wpdb->get_results(
            "SELECT * FROM {$pre}melk_construction_projects
             WHERE status IN ('active','completed','draft')
             ORDER BY updated_at DESC LIMIT 40",
            ARRAY_A
        ) ?: [];

        $allocs = [];
        foreach ( $projects as $p ) {
            $saved = $wpdb->get_results( $wpdb->prepare(
                "SELECT * FROM {$pre}melk_acc_activity_costs WHERE project_id=%d", (int) $p['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[] = [
                    'project_id' => (int) $p['id'],
                    'project_key' => $p['project_key'] ?? '',
                    'title' => $p['title'] ?? '',
                    'lines' => $lines,
                    'person_shares' => array_values( $ps ),
                    'totals' => [
                        'activity_cost' => array_sum( array_column( $lines, 'amount' ) ),
                        'budget' => (float) ( $p['budget'] ?? 0 ),
                    ],
                ];
            } else {
                $allocs[] = self::allocate( $p );
            }
        }
        return self::_graph( $allocs );
    }

    private static function _graph( array $allocs, ?array $single = null ): array {
        $nodes = []; $edges = []; $persons = []; $projects = [];
        $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 ) {
            $pid = 'project:' . (int) $alloc['project_id'];
            $title = $alloc['title'] ?? ( $single['title'] ?? ( '#' . $alloc['project_id'] ) );
            $key = $alloc['project_key'] ?? ( $single['project_key'] ?? '' );
            $budget = (float) ( $alloc['totals']['budget'] ?? 0 );
            $add( $pid, 'project', ( $key ? $key . ' — ' : '' ) . $title, $budget );
            $projects[] = [
                'project_id' => (int) $alloc['project_id'],
                'project_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 = $pid . '>' . $aid;
                if ( ! isset( $edges[ $ek1 ] ) ) {
                    $edges[ $ek1 ] = [ 'from' => $pid, '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'] ?: 'company' ) );
                $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 ),
            'projects' => $projects,
            'role_labels' => self::role_labels(),
        ];
    }
}
