<?php
if ( ! defined( 'ABSPATH' ) ) exit;

/**
 * CPM_Calculator
 * Forward Pass / Backward Pass / Float / Critical Path
 *
 * Convention: exclusive-end (EF = ES + duration)
 * Supports: FS, SS, FF, SF with lag (in working days)
 */
class CPM_Calculator {

    /**
     * Run full CPM calculation.
     *
     * @param string $projectStartDate  'YYYY-MM-DD' (day 0)
     * @param array  $tasks             [ ['id', 'duration', 'duration_unit'], ... ]
     * @param array  $dependencies      [ ['predecessor_id', 'successor_id', 'type', 'lag'], ... ]
     * @return array                    keyed by task id
     */
    public static function calculate( string $projectStartDate, array $tasks, array $deps ): array {
        if ( empty( $tasks ) ) return [];

        // Build working structures
        $taskIds = [];
        $dur     = []; // working days (float)
        foreach ( $tasks as $t ) {
            $id          = (int) $t['id'];
            $taskIds[]   = $id;
            $dur[ $id ]  = self::to_days( (float) ( $t['duration'] ?? 0 ), $t['duration_unit'] ?? 'day' );
        }

        // Index dependencies (only between known tasks)
        $by_succ = []; // by_succ[succId] = [dep, ...]
        $by_pred = []; // by_pred[predId] = [dep, ...]
        $idSet   = array_flip( $taskIds );
        foreach ( $deps as $d ) {
            $pid = (int) $d['predecessor_id'];
            $sid = (int) $d['successor_id'];
            if ( ! isset( $idSet[ $pid ] ) || ! isset( $idSet[ $sid ] ) ) continue;
            $by_succ[ $sid ][] = $d;
            $by_pred[ $pid ][] = $d;
        }

        $order = self::topo_sort( $taskIds, $by_succ );

        // ── Forward pass ─────────────────────────────────────────
        $ES = []; $EF = [];
        foreach ( $order as $id ) {
            $d  = $dur[ $id ];
            $es = 0.0;
            foreach ( $by_succ[ $id ] ?? [] as $dep ) {
                $pid = (int) $dep['predecessor_id'];
                $lag = (float) ( $dep['lag'] ?? 0 );
                switch ( $dep['type'] ?? 'FS' ) {
                    case 'FS': $es = max( $es, ( $EF[ $pid ] ?? 0 ) + $lag );         break;
                    case 'SS': $es = max( $es, ( $ES[ $pid ] ?? 0 ) + $lag );         break;
                    case 'FF': $es = max( $es, ( $EF[ $pid ] ?? 0 ) + $lag - $d );   break;
                    case 'SF': $es = max( $es, ( $ES[ $pid ] ?? 0 ) + $lag - $d );   break;
                }
            }
            $ES[ $id ] = max( 0.0, $es );
            $EF[ $id ] = $ES[ $id ] + $d;
        }

        $proj_end = empty( $EF ) ? 0.0 : max( array_values( $EF ) );

        // ── Backward pass ────────────────────────────────────────
        $LS = []; $LF = [];
        foreach ( array_reverse( $order ) as $id ) {
            $d  = $dur[ $id ];
            $lf = $proj_end;
            foreach ( $by_pred[ $id ] ?? [] as $dep ) {
                $sid = (int) $dep['successor_id'];
                $lag = (float) ( $dep['lag'] ?? 0 );
                switch ( $dep['type'] ?? 'FS' ) {
                    case 'FS': $lf = min( $lf, ( $LS[ $sid ] ?? $proj_end ) - $lag );         break;
                    case 'SS': $lf = min( $lf, ( $LS[ $sid ] ?? $proj_end ) - $lag + $d );   break;
                    case 'FF': $lf = min( $lf, ( $LF[ $sid ] ?? $proj_end ) - $lag );         break;
                    case 'SF': $lf = min( $lf, ( $LF[ $sid ] ?? $proj_end ) - $lag + $d );   break;
                }
            }
            $LF[ $id ] = $lf;
            $LS[ $id ] = $lf - $d;
        }

        // ── Float & critical path ────────────────────────────────
        $base    = new DateTime( $projectStartDate ?: date( 'Y-m-d' ) );
        $results = [];

        foreach ( $taskIds as $id ) {
            $tf = round( $LS[ $id ] - $ES[ $id ], 4 );

            // Free float (from FS successors)
            $ff      = $tf;
            $has_fs  = false;
            foreach ( $by_pred[ $id ] ?? [] as $dep ) {
                if ( ( $dep['type'] ?? 'FS' ) !== 'FS' ) continue;
                $sid  = (int) $dep['successor_id'];
                $lag  = (float) ( $dep['lag'] ?? 0 );
                $cand = ( $ES[ $sid ] ?? $proj_end ) - $lag - $EF[ $id ];
                if ( ! $has_fs || $cand < $ff ) { $ff = $cand; $has_fs = true; }
            }
            $ff = max( 0.0, round( $ff, 4 ) );

            $results[ $id ] = [
                'id'           => $id,
                'early_start'  => self::day_to_date( $base, $ES[ $id ] ),
                'early_finish' => self::day_to_date( $base, max( 0, $EF[ $id ] - 1 ) ),
                'late_start'   => self::day_to_date( $base, $LS[ $id ] ),
                'late_finish'  => self::day_to_date( $base, max( 0, $LF[ $id ] - 1 ) ),
                'total_float'  => max( 0.0, $tf ),
                'free_float'   => $ff,
                'is_critical'  => $tf <= 0.001 ? 1 : 0,
            ];
        }

        return $results;
    }

    /* ── Helpers ──────────────────────────────────────────────── */

    private static function to_days( float $d, string $unit ): float {
        return match ( $unit ) {
            'hour' => $d / 8.0,
            'week' => $d * 5.0,
            default => $d,
        };
    }

    /**
     * Convert a working-day offset from $base to a calendar date (Mon–Fri only).
     */
    private static function day_to_date( DateTime $base, float $day_num ): string {
        static $cache = [];
        $days = (int) round( $day_num );
        $base_key = $base->format( 'Y-m-d' );
        $cache_key = $base_key . ':' . $days;
        if ( isset( $cache[ $cache_key ] ) ) {
            return $cache[ $cache_key ];
        }
        if ( $days <= 0 ) {
            return $cache[ $cache_key ] = $base_key;
        }
        $date  = clone $base;
        $added = 0;
        while ( $added < $days ) {
            $date->modify( '+1 day' );
            if ( (int) $date->format( 'N' ) < 6 ) {
                $added++;
            }
        }
        return $cache[ $cache_key ] = $date->format( 'Y-m-d' );
    }

    /**
     * Kahn's topological sort.
     * $by_succ[succId] = list of deps where succId is the SUCCESSOR.
     */
    private static function topo_sort( array $task_ids, array $by_succ ): array {
        $in_deg   = array_fill_keys( $task_ids, 0 );
        $succs_of = array_fill_keys( $task_ids, [] );

        foreach ( $by_succ as $succ_id => $deps ) {
            if ( ! array_key_exists( $succ_id, $in_deg ) ) continue;
            foreach ( $deps as $dep ) {
                $pred_id = (int) $dep['predecessor_id'];
                if ( ! array_key_exists( $pred_id, $in_deg ) ) continue;
                $in_deg[ $succ_id ]++;
                $succs_of[ $pred_id ][] = $succ_id;
            }
        }

        $queue = [];
        foreach ( $in_deg as $id => $deg ) {
            if ( $deg === 0 ) $queue[] = $id;
        }

        $order = [];
        while ( ! empty( $queue ) ) {
            $id      = array_shift( $queue );
            $order[] = $id;
            foreach ( array_unique( $succs_of[ $id ] ) as $sid ) {
                if ( --$in_deg[ $sid ] === 0 ) $queue[] = $sid;
            }
        }

        // Cycle fallback: append any remaining tasks
        $in_order = array_flip( $order );
        foreach ( $task_ids as $id ) {
            if ( ! isset( $in_order[ $id ] ) ) $order[] = $id;
        }

        return $order;
    }
}
