<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
use App\Services\ScheduledTaskRunner;
use App\Services\ProjectBackupService;
use App\Services\ThirdPartyLeaveSyncService;
use App\Services\ThirdPartyJobNumberSyncService;
use App\Services\ThirdPartySplSyncService;
use App\Models\AppSetting;
use App\Modules\AttendanceReview\Services\AttendanceHistoryRetentionService;
use App\Models\MenuModels;
use App\Models\PermissionModels;
use App\Modules\AttendanceReview\Jobs\SyncPublicApiLogJob;
use App\Modules\MasterKalender\Models\KalenderModel;
use App\Modules\AttendanceReview\Models\AttendanceDailyReviewModel;
use App\Modules\AttendanceReview\Models\AttendanceDailyReviewHistoryModel;
use App\Modules\AttendanceReview\Models\AttendanceExportJobModel;
use App\Modules\LeaveRequest\Models\LeaveRequestModel;
use App\Modules\MasterKaryawan\Models\KaryawanModel;
use App\Modules\MasterProfilBulan\Models\ProfilBulanModel;
use App\Support\SystemDocumentationGenerator;
use App\Modules\MasterBarang\Models\BarangModel;
use App\Modules\MasterGudang\Models\GudangModel;
use App\Modules\MasterSatuan\Models\SatuanModel;
use App\Modules\MasterProject\Models\ProjectModel;
use App\Modules\MasterProject\Models\ProjectProgressInvoiceModel;
use App\Modules\MasterProject\Models\ProjectProgressScheduleModel;
use App\Modules\MasterProject\Models\ProjectStockIssueItemModel;
use App\Modules\MasterProject\Models\ProjectStockIssueModel;
use App\Modules\MasterProject\Models\PurchaseOrderDeliveryItemModel;
use App\Modules\MasterProject\Models\PurchaseOrderDeliveryModel;
use App\Modules\MasterProject\Models\PurchaseOrderItemModel;
use App\Modules\MasterProject\Models\PurchaseOrderModel;
use App\Modules\MasterProject\Models\RabItemModel;
use App\Modules\MasterProject\Models\RabScopeModel;
use App\Modules\MasterProject\Models\WarehouseAdjustmentItemModel;
use App\Modules\MasterProject\Models\WarehouseAdjustmentModel;
use App\Modules\MasterProject\Models\WarehouseStockMovementModel;
use App\Modules\MasterProject\Models\WarehouseTransferItemModel;
use App\Modules\MasterProject\Models\WarehouseTransferModel;
use App\Modules\MasterProject\Models\WorkOrderModel;
use App\Modules\MasterVendor\Models\VendorModel;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Artisan::command('masterproject:seed-dummy {--project_id=} {--force} {--with-invoice}', function () {
    $projectIdOpt = (string) ($this->option('project_id') ?? '');
    $force = (bool) $this->option('force');
    $withInvoice = (bool) $this->option('with-invoice');

    $project = null;
    if ($projectIdOpt !== '') {
        $project = ProjectModel::query()->find((int) $projectIdOpt);
        if (!$project) {
            $this->error('Project tidak ditemukan: ' . $projectIdOpt);
            return 1;
        }
    } else {
        $project = ProjectModel::query()
            ->where('is_active', true)
            ->orderByDesc('id')
            ->first();
        if (!$project) {
            $this->error('Tidak ada project di master_projects.');
            return 1;
        }
    }

    $projectCode = (string) ($project->code_project ?: ($project->code ?: $project->id));

    $existingScopes = RabScopeModel::query()->where('project_id', $project->id)->count();
    $existingRab = RabItemModel::query()->where('project_id', $project->id)->count();
    $existingSchedule = ProjectProgressScheduleModel::query()->where('project_id', $project->id)->count();
    $existingInvoice = ProjectProgressInvoiceModel::query()->where('project_id', $project->id)->count();

    if (!$force && ($existingScopes > 0 || $existingRab > 0 || $existingSchedule > 0 || $existingInvoice > 0)) {
        $this->warn("Data sudah ada untuk project {$projectCode}. Jalankan lagi dengan --force untuk generate ulang.");
        $this->line("Scopes={$existingScopes}, RAB={$existingRab}, Schedules={$existingSchedule}, Invoices={$existingInvoice}");
        return 0;
    }

    DB::beginTransaction();
    try {
        if ($force) {
            ProjectProgressInvoiceModel::query()->where('project_id', $project->id)->delete();
            ProjectProgressScheduleModel::query()->where('project_id', $project->id)->delete();
            RabItemModel::query()->where('project_id', $project->id)->delete();
            RabScopeModel::query()->where('project_id', $project->id)->delete();
            WorkOrderModel::query()->where('project_id', $project->id)->delete();
        }

        $wo = WorkOrderModel::query()
            ->where('project_id', $project->id)
            ->orderByDesc('id')
            ->first();
        if (!$wo) {
            $woNumberBase = 'WO-DUMMY-' . preg_replace('/[^A-Z0-9\\-]/i', '', strtoupper($projectCode));
            $woNumber = $woNumberBase . '-' . Carbon::now()->format('Ymd') . '-' . Str::upper(Str::random(4));
            while (WorkOrderModel::query()->where('wo_number', $woNumber)->exists()) {
                $woNumber = $woNumberBase . '-' . Carbon::now()->format('Ymd') . '-' . Str::upper(Str::random(4));
            }
            $wo = WorkOrderModel::query()->create([
                'project_id' => $project->id,
                'wo_number' => $woNumber,
                'title' => 'Dummy Work Order ' . $projectCode,
                'status' => 'released',
                'description' => 'Generated by masterproject:seed-dummy',
                'is_active' => true,
            ]);
        }

        $scopes = collect();
        $scopeCount = 3;
        for ($i = 1; $i <= $scopeCount; $i++) {
            $code = 'SCP-' . str_pad((string) $i, 3, '0', STR_PAD_LEFT);
            $name = 'Scope Dummy ' . $i;
            $scope = RabScopeModel::query()->create([
                'project_id' => $project->id,
                'work_order_id' => $wo->id,
                'scope_code' => $code,
                'scope_name' => $name,
                'sort_order' => $i,
                'description' => 'Generated by masterproject:seed-dummy',
                'is_active' => true,
            ]);
            $scopes->push($scope);
        }

        $barangList = BarangModel::query()
            ->where('is_active', true)
            ->orderBy('id')
            ->limit(10)
            ->get(['id', 'name', 'default_satuan_id']);

        if ($barangList->isEmpty()) {
            $satuan = SatuanModel::query()->create([
                'code' => 'PCS-DUMMY-' . Str::upper(Str::random(3)),
                'name' => 'Pieces Dummy',
                'base_satuan_id' => null,
                'conversion_value' => 1,
                'description' => 'Generated by masterproject:seed-dummy',
                'is_active' => true,
            ]);
            $barang = BarangModel::query()->create([
                'code' => 'BRG-DUMMY-' . Str::upper(Str::random(4)),
                'name' => 'Barang Dummy',
                'default_satuan_id' => $satuan->id,
                'description' => 'Generated by masterproject:seed-dummy',
                'is_active' => true,
            ]);
            $barangList = collect([$barang]);
        }

        $hasScopeId = Schema::hasColumn('master_rab_items', 'scope_id');
        $hasBarangId = Schema::hasColumn('master_rab_items', 'barang_id');
        $hasMerk = Schema::hasColumn('master_rab_items', 'merk');
        $hasRincian = Schema::hasColumn('master_rab_items', 'rincian');
        $hasSatuanId = Schema::hasColumn('master_rab_items', 'satuan_id');
        $hasBaseSatuanId = Schema::hasColumn('master_rab_items', 'base_satuan_id');

        $itemsPerScope = 5;
        $rabInserted = 0;
        foreach ($scopes as $scope) {
            for ($i = 1; $i <= $itemsPerScope; $i++) {
                $barang = $barangList[($rabInserted + $i) % max(1, $barangList->count())];
                $qty = rand(1, 10);
                $unitPrice = rand(50_000, 350_000);
                $total = $qty * $unitPrice;

                $codeBase = 'RAB-' . strtoupper(preg_replace('/[^A-Z0-9\\-]/i', '', $projectCode)) . '-' . $scope->scope_code . '-' . str_pad((string) $i, 2, '0', STR_PAD_LEFT);
                $itemCode = $codeBase . '-' . Str::upper(Str::random(3));
                while (DB::table('master_rab_items')->where('item_code', $itemCode)->exists()) {
                    $itemCode = $codeBase . '-' . Str::upper(Str::random(3));
                }

                $payload = [
                    'project_id' => $project->id,
                    'work_order_id' => $wo->id,
                    'item_code' => $itemCode,
                    'item_name' => 'Dummy Item ' . $scope->scope_code . ' #' . $i,
                    'qty' => $qty,
                    'unit' => 'pcs',
                    'unit_price' => $unitPrice,
                    'total_price' => $total,
                    'is_active' => true,
                    'created_at' => now(),
                    'updated_at' => now(),
                ];
                if ($hasScopeId) {
                    $payload['scope_id'] = $scope->id;
                }
                if ($hasBarangId) {
                    $payload['barang_id'] = $barang->id;
                }
                if ($hasMerk) {
                    $payload['merk'] = 'DUMMY';
                }
                if ($hasRincian) {
                    $payload['rincian'] = 'Generated by masterproject:seed-dummy';
                }
                if ($hasSatuanId && !empty($barang->default_satuan_id)) {
                    $payload['satuan_id'] = $barang->default_satuan_id;
                }
                if ($hasBaseSatuanId && !empty($barang->default_satuan_id)) {
                    $payload['base_satuan_id'] = $barang->default_satuan_id;
                }

                DB::table('master_rab_items')->insert($payload);
                $rabInserted++;
            }
        }

        $baseDate = Carbon::now()->startOfDay();
        $scheduleInserted = 0;
        foreach ($scopes as $idx => $scope) {
            $start = $baseDate->copy()->addDays($idx * 7);
            $mid = $start->copy()->addDays(3);
            $end = $start->copy()->addDays(6);

            $rows = [
                [
                    'activity_code' => $scope->scope_code . '-A1',
                    'activity_name' => 'Dummy Activity 1',
                    'start_date' => $start->format('Y-m-d'),
                    'end_date' => $mid->format('Y-m-d'),
                    'sort_order' => 10,
                ],
                [
                    'activity_code' => $scope->scope_code . '-A2',
                    'activity_name' => 'Dummy Activity 2',
                    'start_date' => $mid->copy()->addDay()->format('Y-m-d'),
                    'end_date' => $end->format('Y-m-d'),
                    'sort_order' => 20,
                ],
            ];
            foreach ($rows as $r) {
                ProjectProgressScheduleModel::query()->create([
                    'project_id' => $project->id,
                    'activity_code' => $r['activity_code'],
                    'activity_name' => $r['activity_name'],
                    'scope_id' => $scope->id,
                    'scope_name' => $scope->scope_name,
                    'start_date' => $r['start_date'],
                    'end_date' => $r['end_date'],
                    'weight_percent' => 0,
                    'target_progress_percent' => 0,
                    'actual_progress_percent' => 0,
                    'billable_progress_percent' => 0,
                    'status' => 'planned',
                    'sort_order' => $r['sort_order'],
                    'notes' => 'Generated by masterproject:seed-dummy',
                    'is_active' => true,
                ]);
                $scheduleInserted++;
            }
        }

        $totals = DB::table('master_rab_items')
            ->select('scope_id', DB::raw('SUM(total_price) as total'))
            ->where('project_id', $project->id)
            ->where('is_active', true)
            ->whereNotNull('scope_id')
            ->groupBy('scope_id')
            ->get()
            ->keyBy('scope_id');
        $grand = (float) collect($totals)->sum(fn ($r) => (float) $r->total);

        $scheduleRows = ProjectProgressScheduleModel::query()
            ->where('project_id', $project->id)
            ->where('is_active', true)
            ->orderBy('scope_id')
            ->orderBy('sort_order')
            ->orderBy('id')
            ->get();

        $runningTotal = 0.0;
        $lastId = null;

        foreach ($scheduleRows->groupBy(fn ($r) => (int) $r->scope_id) as $scopeId => $rows) {
            $scopeTotal = (float) ($totals[$scopeId]->total ?? 0);
            $scopeWeight = $grand > 0 ? round(($scopeTotal / $grand) * 100, 3) : 0.0;
            $count = max(1, (int) $rows->count());
            $base = round($scopeWeight / $count, 3);
            $sumAssigned = 0.0;

            foreach ($rows->values() as $idx => $row) {
                $w = $base;
                if ($idx === $count - 1) {
                    $w = round($scopeWeight - $sumAssigned, 3);
                }
                $row->weight_percent = $w;
                $row->target_progress_percent = $w;
                $row->actual_progress_percent = round($w * 0.75, 3);
                $row->billable_progress_percent = round($w * 0.55, 3);
                $row->save();
                $sumAssigned += $w;
                $runningTotal += $w;
                $lastId = $row->id;
            }
        }

        if ($lastId !== null) {
            $delta = round(100.0 - $runningTotal, 3);
            if (abs($delta) > 0.0001) {
                $row = ProjectProgressScheduleModel::query()->find($lastId);
                if ($row) {
                    $row->weight_percent = round(((float) $row->weight_percent) + $delta, 3);
                    $row->target_progress_percent = (float) $row->weight_percent;
                    $row->actual_progress_percent = round(((float) $row->weight_percent) * 0.75, 3);
                    $row->billable_progress_percent = round(((float) $row->weight_percent) * 0.55, 3);
                    $row->save();
                }
            }
        }

        if ($withInvoice) {
            $scheduleFirst = ProjectProgressScheduleModel::query()
                ->where('project_id', $project->id)
                ->orderBy('id')
                ->first();

            for ($i = 1; $i <= 2; $i++) {
                $inv = 'INV-DUMMY-' . strtoupper(preg_replace('/[^A-Z0-9\\-]/i', '', $projectCode)) . '-' . Carbon::now()->format('YmdHis') . '-' . $i;
                while (ProjectProgressInvoiceModel::query()->where('invoice_number', $inv)->exists()) {
                    $inv = $inv . '-' . Str::upper(Str::random(2));
                }
                $amount = $i === 1 ? 25_000_000 : 55_000_000;
                ProjectProgressInvoiceModel::query()->create([
                    'project_id' => $project->id,
                    'progress_schedule_id' => $scheduleFirst?->id,
                    'invoice_number' => $inv,
                    'invoice_date' => Carbon::now()->subDays(7 * (2 - $i))->format('Y-m-d'),
                    'due_date' => Carbon::now()->addDays(14)->format('Y-m-d'),
                    'progress_percent' => $i === 1 ? 15 : 35,
                    'amount' => $amount,
                    'currency_code' => 'IDR',
                    'exchange_rate_to_idr' => 1,
                    'amount_idr' => $amount,
                    'status' => 'approved',
                    'notes' => 'Generated by masterproject:seed-dummy',
                    'is_active' => true,
                ]);
            }
        }

        DB::commit();
    } catch (\Throwable $e) {
        DB::rollBack();
        $this->error($e->getMessage());
        return 1;
    }

    $this->info("Dummy data OK untuk project {$projectCode} (ID={$project->id}).");
    $this->line("Buka: /master-project/rab-page?project_id={$project->id}&tab=schedule");
    $this->line("Buka Kurva: /master-project/rab-page?project_id={$project->id}&tab=curve");
    return 0;
})->purpose('Seed dummy RAB + scope + schedule + kurva S untuk 1 project');

Artisan::command('masterproject:seed-warehouse-dummy {--project_id=} {--force}', function () {
    $projectIdOpt = (string) ($this->option('project_id') ?? '');
    $force = (bool) $this->option('force');
    $marker = 'Generated by masterproject:seed-warehouse-dummy';

    $project = null;
    if ($projectIdOpt !== '') {
        $project = ProjectModel::query()->find((int) $projectIdOpt);
        if (!$project) {
            $this->error('Project tidak ditemukan: ' . $projectIdOpt);
            return 1;
        }
    } else {
        $project = ProjectModel::query()
            ->where('is_active', true)
            ->orderByDesc('id')
            ->first();
        if (!$project) {
            $this->error('Tidak ada project aktif.');
            return 1;
        }
    }

    $projectCode = (string) ($project->code_project ?: ($project->code ?: $project->id));

    // Warehouse flows require valid units. Legacy master data sometimes has missing default_satuan_id on barang
    // and missing satuan_id/base_satuan_id on RAB items. For dummy seed, we auto-fix those to a safe fallback.
    $fallbackSatuan = SatuanModel::query()
        ->whereIn('code', ['Pcs', 'PCS', 'Ea', 'Nos', 'Unit'])
        ->orderBy('id')
        ->first();
    if (!$fallbackSatuan) {
        $fallbackSatuan = SatuanModel::query()->create([
            'code' => 'PCS',
            'name' => 'Pcs',
            'base_satuan_id' => null,
            'conversion_value' => 1,
            'description' => $marker,
            'is_active' => true,
        ]);
    }
    $rabItems = RabItemModel::query()
        ->with('barang:id,default_satuan_id')
        ->where('project_id', $project->id)
        ->where('is_active', true)
        ->whereNotNull('barang_id')
        ->orderBy('id')
        ->get();

    if ($rabItems->count() < 2) {
        $this->warn('RAB barang belum cukup. Menjalankan masterproject:seed-dummy lebih dulu.');
        Artisan::call('masterproject:seed-dummy', [
            '--project_id' => (int) $project->id,
            '--with-invoice' => true,
        ]);
        $rabItems = RabItemModel::query()
            ->with('barang:id,default_satuan_id')
            ->where('project_id', $project->id)
            ->where('is_active', true)
            ->whereNotNull('barang_id')
            ->orderBy('id')
            ->get();
    }

    if ($rabItems->count() < 2) {
        $this->error('Tidak cukup item RAB yang terhubung ke master barang.');
        return 1;
    }

    // Ensure master barang has default satuan.
    $barangIds = $rabItems->pluck('barang_id')->filter()->unique()->values();
    if ($barangIds->isNotEmpty()) {
        $barangToFix = BarangModel::query()
            ->whereIn('id', $barangIds->all())
            ->where(function ($q) {
                $q->whereNull('default_satuan_id')->orWhere('default_satuan_id', 0);
            })
            ->pluck('id')
            ->values();
        if ($barangToFix->isNotEmpty()) {
            BarangModel::query()
                ->whereIn('id', $barangToFix->all())
                ->update(['default_satuan_id' => (int) $fallbackSatuan->id]);
        }
    }

    // Ensure RAB items have satuan/base_satuan.
    RabItemModel::query()
        ->where('project_id', $project->id)
        ->where('is_active', true)
        ->whereNotNull('barang_id')
        ->where(function ($q) {
            $q->whereNull('satuan_id')
                ->orWhereNull('base_satuan_id')
                ->orWhere('satuan_id', 0)
                ->orWhere('base_satuan_id', 0);
        })
        ->update([
            'satuan_id' => (int) $fallbackSatuan->id,
            'base_satuan_id' => (int) $fallbackSatuan->id,
        ]);

    // Reload after auto-fix so relations reflect updated values.
    $rabItems = RabItemModel::query()
        ->with('barang:id,default_satuan_id')
        ->where('project_id', $project->id)
        ->where('is_active', true)
        ->whereNotNull('barang_id')
        ->orderBy('id')
        ->get();

    $existingGenerated = PurchaseOrderModel::query()
        ->where('project_id', $project->id)
        ->where('notes', 'like', '%' . $marker . '%')
        ->count();

    if (!$force && $existingGenerated > 0) {
        $this->warn("Dummy gudang sudah ada untuk project {$projectCode}. Jalankan lagi dengan --force untuk generate ulang.");
        return 0;
    }

    $makeUniqueCode = function (string $table, string $field, string $base): string {
        $candidate = $base;
        $counter = 1;
        while (DB::table($table)->where($field, $candidate)->exists()) {
            $counter++;
            $candidate = $base . '-' . str_pad((string) $counter, 2, '0', STR_PAD_LEFT);
        }
        return $candidate;
    };

    DB::beginTransaction();
    try {
        if ($force) {
            $deliveryIds = PurchaseOrderDeliveryModel::query()->where('notes', 'like', '%' . $marker . '%')->pluck('id');
            PurchaseOrderDeliveryItemModel::query()->whereIn('purchase_order_delivery_id', $deliveryIds)->delete();
            PurchaseOrderDeliveryModel::query()->whereIn('id', $deliveryIds)->delete();

            $poIds = PurchaseOrderModel::query()
                ->where('project_id', $project->id)
                ->where('notes', 'like', '%' . $marker . '%')
                ->pluck('id');
            PurchaseOrderItemModel::query()->whereIn('purchase_order_id', $poIds)->delete();
            PurchaseOrderModel::query()->whereIn('id', $poIds)->delete();

            $issueIds = ProjectStockIssueModel::query()
                ->where('project_id', $project->id)
                ->where('notes', 'like', '%' . $marker . '%')
                ->pluck('id');
            ProjectStockIssueItemModel::query()->whereIn('stock_issue_id', $issueIds)->delete();
            ProjectStockIssueModel::query()->whereIn('id', $issueIds)->delete();

            $transferIds = WarehouseTransferModel::query()->where('notes', 'like', '%' . $marker . '%')->pluck('id');
            WarehouseTransferItemModel::query()->whereIn('warehouse_transfer_id', $transferIds)->delete();
            WarehouseTransferModel::query()->whereIn('id', $transferIds)->delete();

            $adjustmentIds = WarehouseAdjustmentModel::query()->where('notes', 'like', '%' . $marker . '%')->pluck('id');
            WarehouseAdjustmentItemModel::query()->whereIn('warehouse_adjustment_id', $adjustmentIds)->delete();
            WarehouseAdjustmentModel::query()->whereIn('id', $adjustmentIds)->delete();

            WarehouseStockMovementModel::query()
                ->where(function ($q) use ($marker, $project) {
                    $q->where('notes', 'like', '%' . $marker . '%')
                        ->orWhere(function ($sub) use ($project) {
                            $sub->where('project_id', $project->id)
                                ->whereIn('reference_type', ['purchase_order_delivery', 'warehouse_transfer', 'warehouse_adjustment', 'project_stock_issue']);
                        });
                })
                ->delete();
        }

        $gudangMain = GudangModel::query()->firstOrCreate(
            ['code' => 'GDG-DMY-UTM'],
            [
                'name' => 'Gudang Utama Dummy',
                'warehouse_type' => 'UTAMA',
                'address' => 'Generated Dummy Main Warehouse',
                'description' => $marker,
                'is_active' => true,
            ]
        );
        $gudangBranch = GudangModel::query()->firstOrCreate(
            ['code' => 'GDG-DMY-CBG'],
            [
                'name' => 'Gudang Cabang Dummy',
                'warehouse_type' => 'CABANG',
                'address' => 'Generated Dummy Branch Warehouse',
                'description' => $marker,
                'is_active' => true,
            ]
        );

        $vendor = VendorModel::query()->firstOrCreate(
            ['code' => 'VND-DMY-WHS'],
            [
                'name' => 'Vendor Dummy Warehouse',
                'contact_person' => 'Dummy PIC',
                'phone' => '081234567890',
                'email' => 'dummy.warehouse@example.com',
                'address' => 'Generated Dummy Vendor',
                'is_active' => true,
            ]
        );

        $itemA = $rabItems->values()->get(0);
        $itemB = $rabItems->values()->get(1);
        $unitIdForRab = function ($rabItem): int {
            return (int) ($rabItem->satuan_id ?: $rabItem->base_satuan_id ?: ($rabItem->barang?->default_satuan_id ?: 0));
        };
        $baseUnitIdForRab = function ($rabItem): int {
            return (int) ($rabItem->base_satuan_id ?: $rabItem->satuan_id ?: ($rabItem->barang?->default_satuan_id ?: 0));
        };

        if ($unitIdForRab($itemA) <= 0 || $unitIdForRab($itemB) <= 0) {
            throw new \RuntimeException('Ada item RAB dummy yang belum memiliki satuan valid.');
        }

        $poNumber = $makeUniqueCode('master_purchase_orders', 'po_number', 'PO-DMY-' . preg_replace('/[^A-Z0-9]/i', '', strtoupper($projectCode)) . '-' . now()->format('Ymd'));
        $po = PurchaseOrderModel::query()->create([
            'project_id' => (int) $project->id,
            'material_request_id' => null,
            'vendor_id' => (int) $vendor->id,
            'po_number' => $poNumber,
            'po_date' => now()->subDays(5)->toDateString(),
            'status' => 'approved',
            'total_amount' => 0,
            'notes' => $marker,
            'is_active' => true,
        ]);

        $qtyA = 8.0;
        $qtyB = 5.0;
        $poItemA = PurchaseOrderItemModel::query()->create([
            'purchase_order_id' => (int) $po->id,
            'material_request_item_id' => null,
            'rab_item_id' => (int) $itemA->id,
            'qty' => $qtyA,
            'unit_price' => (float) ($itemA->unit_price ?: 100000),
            'total_price' => $qtyA * (float) ($itemA->unit_price ?: 100000),
            'notes' => $marker,
        ]);
        $poItemB = PurchaseOrderItemModel::query()->create([
            'purchase_order_id' => (int) $po->id,
            'material_request_item_id' => null,
            'rab_item_id' => (int) $itemB->id,
            'qty' => $qtyB,
            'unit_price' => (float) ($itemB->unit_price ?: 150000),
            'total_price' => $qtyB * (float) ($itemB->unit_price ?: 150000),
            'notes' => $marker,
        ]);
        $po->total_amount = (float) ($poItemA->total_price + $poItemB->total_price);
        $po->save();

        $delivery = PurchaseOrderDeliveryModel::query()->create([
            'purchase_order_id' => (int) $po->id,
            'gudang_id' => (int) $gudangMain->id,
            'delivery_number' => $makeUniqueCode('master_purchase_order_deliveries', 'delivery_number', 'GR-DMY-' . now()->format('Ymd')),
            'supplier_delivery_note_number' => 'SJ-DMY-' . now()->format('Ymd'),
            'delivery_date' => now()->subDays(4)->toDateString(),
            'proof_file_path' => null,
            'notes' => $marker,
            'status' => 'received',
            'received_by' => 1,
            'is_active' => true,
        ]);

        foreach ([[$poItemA, $itemA, $qtyA], [$poItemB, $itemB, $qtyB]] as [$poItem, $rabItem, $qty]) {
            $satuanId = $unitIdForRab($rabItem);
            $baseSatuanId = $baseUnitIdForRab($rabItem);
            PurchaseOrderDeliveryItemModel::query()->create([
                'purchase_order_delivery_id' => (int) $delivery->id,
                'purchase_order_item_id' => (int) $poItem->id,
                'rab_item_id' => (int) $rabItem->id,
                'satuan_id' => $satuanId,
                'base_satuan_id' => $baseSatuanId,
                'qty' => (float) $qty,
                'qty_base' => (float) $qty,
                'notes' => $marker,
            ]);

            WarehouseStockMovementModel::query()->create([
                'gudang_id' => (int) $gudangMain->id,
                'barang_id' => (int) $rabItem->barang_id,
                'project_id' => (int) $project->id,
                'rab_item_id' => (int) $rabItem->id,
                'movement_type' => 'IN',
                'reference_type' => 'purchase_order_delivery',
                'reference_id' => (int) $delivery->id,
                'reference_number' => $delivery->delivery_number,
                'movement_date' => $delivery->delivery_date,
                'satuan_id' => $satuanId,
                'base_satuan_id' => $baseSatuanId,
                'qty' => (float) $qty,
                'qty_base' => (float) $qty,
                'notes' => $marker,
                'created_by' => 1,
            ]);
        }

        $transferQty = 3.0;
        $transfer = WarehouseTransferModel::query()->create([
            'from_gudang_id' => (int) $gudangMain->id,
            'to_gudang_id' => (int) $gudangBranch->id,
            'transfer_number' => $makeUniqueCode('master_warehouse_transfers', 'transfer_number', 'TRF-DMY-' . now()->format('Ymd')),
            'transfer_date' => now()->subDays(3)->toDateString(),
            'driver_name' => 'Driver Dummy',
            'recipient_name' => 'PIC Cabang Dummy',
            'notes' => $marker,
            'status' => 'posted',
            'created_by' => 1,
            'is_active' => true,
        ]);
        WarehouseTransferItemModel::query()->create([
            'warehouse_transfer_id' => (int) $transfer->id,
            'barang_id' => (int) $itemA->barang_id,
            'satuan_id' => $unitIdForRab($itemA),
            'base_satuan_id' => $baseUnitIdForRab($itemA),
            'qty' => $transferQty,
            'qty_base' => $transferQty,
            'stock_before_base' => $qtyA,
            'stock_after_base' => $qtyA - $transferQty,
            'notes' => $marker,
        ]);
        WarehouseStockMovementModel::query()->create([
            'gudang_id' => (int) $gudangMain->id,
            'barang_id' => (int) $itemA->barang_id,
            'project_id' => null,
            'rab_item_id' => null,
            'movement_type' => 'OUT',
            'reference_type' => 'warehouse_transfer',
            'reference_id' => (int) $transfer->id,
            'reference_number' => $transfer->transfer_number,
            'movement_date' => $transfer->transfer_date,
            'satuan_id' => $unitIdForRab($itemA),
            'base_satuan_id' => $baseUnitIdForRab($itemA),
            'qty' => $transferQty,
            'qty_base' => $transferQty,
            'notes' => $marker,
            'created_by' => 1,
        ]);
        WarehouseStockMovementModel::query()->create([
            'gudang_id' => (int) $gudangBranch->id,
            'barang_id' => (int) $itemA->barang_id,
            'project_id' => null,
            'rab_item_id' => null,
            'movement_type' => 'IN',
            'reference_type' => 'warehouse_transfer',
            'reference_id' => (int) $transfer->id,
            'reference_number' => $transfer->transfer_number,
            'movement_date' => $transfer->transfer_date,
            'satuan_id' => $unitIdForRab($itemA),
            'base_satuan_id' => $baseUnitIdForRab($itemA),
            'qty' => $transferQty,
            'qty_base' => $transferQty,
            'notes' => $marker,
            'created_by' => 1,
        ]);

        // Second dummy transfer to make transfer page clearly populated.
        $transferQtyB = 2.0;
        $transfer2 = WarehouseTransferModel::query()->create([
            'from_gudang_id' => (int) $gudangMain->id,
            'to_gudang_id' => (int) $gudangBranch->id,
            'transfer_number' => $makeUniqueCode('master_warehouse_transfers', 'transfer_number', 'TRF-DMY-' . now()->format('Ymd') . '-B'),
            'transfer_date' => now()->subDays(2)->toDateString(),
            'driver_name' => 'Driver Dummy 2',
            'recipient_name' => 'PIC Cabang Dummy',
            'notes' => $marker,
            'status' => 'posted',
            'created_by' => 1,
            'is_active' => true,
        ]);
        WarehouseTransferItemModel::query()->create([
            'warehouse_transfer_id' => (int) $transfer2->id,
            'barang_id' => (int) $itemB->barang_id,
            'satuan_id' => $unitIdForRab($itemB),
            'base_satuan_id' => $baseUnitIdForRab($itemB),
            'qty' => $transferQtyB,
            'qty_base' => $transferQtyB,
            'stock_before_base' => $qtyB,
            'stock_after_base' => $qtyB - $transferQtyB,
            'notes' => $marker,
        ]);
        WarehouseStockMovementModel::query()->create([
            'gudang_id' => (int) $gudangMain->id,
            'barang_id' => (int) $itemB->barang_id,
            'project_id' => null,
            'rab_item_id' => null,
            'movement_type' => 'OUT',
            'reference_type' => 'warehouse_transfer',
            'reference_id' => (int) $transfer2->id,
            'reference_number' => $transfer2->transfer_number,
            'movement_date' => $transfer2->transfer_date,
            'satuan_id' => $unitIdForRab($itemB),
            'base_satuan_id' => $baseUnitIdForRab($itemB),
            'qty' => $transferQtyB,
            'qty_base' => $transferQtyB,
            'notes' => $marker,
            'created_by' => 1,
        ]);
        WarehouseStockMovementModel::query()->create([
            'gudang_id' => (int) $gudangBranch->id,
            'barang_id' => (int) $itemB->barang_id,
            'project_id' => null,
            'rab_item_id' => null,
            'movement_type' => 'IN',
            'reference_type' => 'warehouse_transfer',
            'reference_id' => (int) $transfer2->id,
            'reference_number' => $transfer2->transfer_number,
            'movement_date' => $transfer2->transfer_date,
            'satuan_id' => $unitIdForRab($itemB),
            'base_satuan_id' => $baseUnitIdForRab($itemB),
            'qty' => $transferQtyB,
            'qty_base' => $transferQtyB,
            'notes' => $marker,
            'created_by' => 1,
        ]);

        $adjustQty = 1.0;
        $adjustment = WarehouseAdjustmentModel::query()->create([
            'gudang_id' => (int) $gudangBranch->id,
            'adjustment_number' => $makeUniqueCode('master_warehouse_adjustments', 'adjustment_number', 'ADJ-DMY-' . now()->format('Ymd')),
            'adjustment_date' => now()->subDays(2)->toDateString(),
            'adjustment_type' => 'correction',
            'notes' => $marker,
            'created_by' => 1,
            'is_active' => true,
        ]);
        WarehouseAdjustmentItemModel::query()->create([
            'warehouse_adjustment_id' => (int) $adjustment->id,
            'barang_id' => (int) $itemA->barang_id,
            'satuan_id' => $unitIdForRab($itemA),
            'base_satuan_id' => $baseUnitIdForRab($itemA),
            'qty' => $adjustQty,
            'qty_base' => $adjustQty,
            'stock_before_base' => $transferQty,
            'stock_after_base' => $transferQty + $adjustQty,
            'movement_type' => 'IN',
            'notes' => $marker,
        ]);
        WarehouseStockMovementModel::query()->create([
            'gudang_id' => (int) $gudangBranch->id,
            'barang_id' => (int) $itemA->barang_id,
            'project_id' => null,
            'rab_item_id' => null,
            'movement_type' => 'IN',
            'reference_type' => 'warehouse_adjustment',
            'reference_id' => (int) $adjustment->id,
            'reference_number' => $adjustment->adjustment_number,
            'movement_date' => $adjustment->adjustment_date,
            'satuan_id' => $unitIdForRab($itemA),
            'base_satuan_id' => $baseUnitIdForRab($itemA),
            'qty' => $adjustQty,
            'qty_base' => $adjustQty,
            'notes' => $marker,
            'created_by' => 1,
        ]);

        $issueQty = 2.0;
        $issue = ProjectStockIssueModel::query()->create([
            'project_id' => (int) $project->id,
            'gudang_id' => (int) $gudangBranch->id,
            'issue_number' => $makeUniqueCode('master_project_stock_issues', 'issue_number', 'OUT-DMY-' . now()->format('Ymd')),
            'issue_date' => now()->subDay()->toDateString(),
            'recipient_name' => 'PIC Project Dummy',
            'notes' => $marker,
            'status' => 'posted',
            'created_by' => 1,
            'is_active' => true,
        ]);
        ProjectStockIssueItemModel::query()->create([
            'stock_issue_id' => (int) $issue->id,
            'rab_item_id' => (int) $itemA->id,
            'barang_id' => (int) $itemA->barang_id,
            'satuan_id' => $unitIdForRab($itemA),
            'base_satuan_id' => $baseUnitIdForRab($itemA),
            'qty' => $issueQty,
            'qty_base' => $issueQty,
            'stock_before_base' => $transferQty + $adjustQty,
            'stock_after_base' => $transferQty + $adjustQty - $issueQty,
            'notes' => $marker,
        ]);
        WarehouseStockMovementModel::query()->create([
            'gudang_id' => (int) $gudangBranch->id,
            'barang_id' => (int) $itemA->barang_id,
            'project_id' => (int) $project->id,
            'rab_item_id' => (int) $itemA->id,
            'movement_type' => 'OUT',
            'reference_type' => 'project_stock_issue',
            'reference_id' => (int) $issue->id,
            'reference_number' => $issue->issue_number,
            'movement_date' => $issue->issue_date,
            'satuan_id' => $unitIdForRab($itemA),
            'base_satuan_id' => $baseUnitIdForRab($itemA),
            'qty' => $issueQty,
            'qty_base' => $issueQty,
            'notes' => $marker,
            'created_by' => 1,
        ]);

        DB::commit();
    } catch (\Throwable $e) {
        DB::rollBack();
        $this->error($e->getMessage());
        return 1;
    }

    $this->info("Dummy gudang OK untuk project {$projectCode} (ID={$project->id}).");
    $this->line('Stok: /master-project/warehouse-stock');
    $this->line('Penerimaan: /master-project/warehouse-receipts');
    $this->line('Pengeluaran: /master-project/warehouse-issues');
    $this->line('Transfer: /master-project/warehouse-transfers');
    $this->line('Adjustment: /master-project/warehouse-adjustments');
    return 0;
})->purpose('Seed dummy warehouse flow: receipt, stock, transfer, adjustment, and issue for one project');

$processRealtimeQueuesOnce = function (): array {
    try {
        $pending = DB::table('jobs')
            ->whereIn('queue', ['scheduler', 'attendance-review'])
            ->whereNull('reserved_at')
            ->count();

        if ($pending <= 0) {
            return [
                'ran' => false,
                'pending' => 0,
                'status' => 'idle',
                'output' => '',
            ];
        }

        Artisan::call('queue:work', [
            '--queue' => 'scheduler,attendance-review',
            '--once' => true,
            '--sleep' => 0,
            '--tries' => 1,
            '--timeout' => 7200,
        ]);

        return [
            'ran' => true,
            'pending' => (int) $pending,
            'status' => 'processed_once',
            'output' => trim((string) Artisan::output()),
        ];
    } catch (Throwable $e) {
        return [
            'ran' => false,
            'pending' => null,
            'status' => 'failed',
            'output' => $e->getMessage(),
        ];
    }
};

Artisan::command('scheduler:run-dynamic', function (ScheduledTaskRunner $runner) use ($processRealtimeQueuesOnce) {
    $results = $runner->runDueTasks();
    $queueFallback = $processRealtimeQueuesOnce();
    if (empty($results)) {
        $message = 'No due scheduled tasks';
        if (($queueFallback['ran'] ?? false) === true) {
            $message .= ' | queue processed once (' . (int) ($queueFallback['pending'] ?? 0) . ' pending)';
        }
        $this->info($message);
        return;
    }

    foreach ($results as $item) {
        $this->line("#{$item['task_id']} {$item['name']} => {$item['status']} ({$item['code']})");
    }
})->purpose('Run due tasks from scheduled_tasks table');

Artisan::command('scheduler:ui-worker {--sleep=30 : Jeda antar tick dalam detik}', function (ScheduledTaskRunner $runner) use ($processRealtimeQueuesOnce) {
    $sleep = max(5, min(60, (int) ($this->option('sleep') ?: 30)));
    $this->info('UI scheduler worker started. Tick every ' . $sleep . ' seconds.');

    while (true) {
        try {
            $results = $runner->runDueTasks();
            $queueFallback = $processRealtimeQueuesOnce();
            $stamp = now()->format('Y-m-d H:i:s');
            if (empty($results)) {
                $line = "[{$stamp}] No due scheduled tasks";
                if (($queueFallback['ran'] ?? false) === true) {
                    $line .= ' | queue processed once (' . (int) ($queueFallback['pending'] ?? 0) . ' pending)';
                }
                $this->line($line);
            } else {
                foreach ($results as $item) {
                    $this->line("[{$stamp}] #{$item['task_id']} {$item['name']} => {$item['status']} ({$item['code']})");
                }
                if (($queueFallback['ran'] ?? false) === true) {
                    $this->line("[{$stamp}] queue processed once (" . (int) ($queueFallback['pending'] ?? 0) . ' pending)');
                }
            }
        } catch (\Throwable $e) {
            $this->error('UI scheduler worker error: ' . $e->getMessage());
        }

        sleep($sleep);
    }
})->purpose('Run dynamic scheduler continuously from UI without server crontab');

Schedule::command('scheduler:run-dynamic')
    ->everyMinute()
    ->withoutOverlapping();

Schedule::command('attendance-review:prune-histories')
    ->dailyAt('02:45')
    ->withoutOverlapping();

Schedule::command('thirdparty:sync-suite --only=masterjob,spl,leave --rolling-days=3 --sync-leave-approval')
    ->dailyAt('00:20')
    ->withoutOverlapping();

Schedule::command('attendance:sync-daily --mode=today --lookback-days=1 --run-detect')
    ->everyTenMinutes()
    ->withoutOverlapping();

Artisan::command('attendance:sync-daily
    {--mode=yesterday : today atau yesterday}
    {--date= : Satu tanggal spesifik YYYY-MM-DD}
    {--from= : Tanggal awal YYYY-MM-DD}
    {--to= : Tanggal akhir YYYY-MM-DD}
    {--lookback-days= : Ambil ulang N hari ke belakang sampai mode/date berjalan, maksimal 31}
    {--profil_bulan_id= : ID profil bulan, kosongkan untuk auto-detect}
    {--base-url= : Override host API attendance}
    {--api-key= : Simpan/update token API attendance}
    {--sn= : Filter SN/device}
    {--emp-code= : Filter employee code}
    {--limit=500 : Limit per halaman API}
    {--source=wdms-public-api : Source log attendance}
    {--run-detect : Jalankan detection setelah sync selesai}
    {--override-lock : Bypass lock ingest tanggal lama}', function () use ($processRealtimeQueuesOnce) {
    $mode = strtolower(trim((string) ($this->option('mode') ?: 'yesterday')));
    if (!in_array($mode, ['today', 'yesterday'], true)) {
        $this->error('Mode harus today atau yesterday.');
        return 1;
    }

    try {
        if ($this->option('from') || $this->option('to')) {
            $startDate = Carbon::parse((string) ($this->option('from') ?: $this->option('to')))->startOfDay();
            $endDate = Carbon::parse((string) ($this->option('to') ?: $this->option('from')))->startOfDay();
        } elseif ($this->option('date')) {
            $startDate = Carbon::parse((string) $this->option('date'))->startOfDay();
            $endDate = $startDate->copy();
        } else {
            $endDate = Carbon::today(config('app.timezone'))->startOfDay();
            if ($mode === 'yesterday') {
                $endDate->subDay();
            }
            $lookbackDays = $this->option('lookback-days') !== null && $this->option('lookback-days') !== ''
                ? (int) $this->option('lookback-days')
                : 0;
            if ($lookbackDays < 0) {
                $this->error('lookback-days tidak boleh minus.');
                return 1;
            }
            if ($lookbackDays > 31) {
                $this->error('lookback-days maksimal 31.');
                return 1;
            }
            $startDate = $endDate->copy()->subDays($lookbackDays);
        }
    } catch (Throwable $e) {
        $this->error('Tanggal tidak valid: ' . $e->getMessage());
        return 1;
    }

    if ($startDate->gt($endDate)) {
        [$startDate, $endDate] = [$endDate, $startDate];
    }
    if ($startDate->diffInDays($endDate) > 31) {
        $this->error('Range tanggal maksimal 31 hari.');
        return 1;
    }

    $profilId = (int) ($this->option('profil_bulan_id') ?: 0);
    if ($profilId <= 0) {
        $profil = ProfilBulanModel::query()
            ->where('is_active', true)
            ->whereDate('start_date', '<=', $endDate->format('Y-m-d'))
            ->whereDate('end_date', '>=', $startDate->format('Y-m-d'))
            ->orderByDesc('start_date')
            ->first(['id', 'code', 'name']);
        if (!$profil) {
            $this->error('Profil bulan aktif tidak ditemukan untuk range ' . $startDate->format('Y-m-d') . ' s/d ' . $endDate->format('Y-m-d'));
            return 1;
        }
        $profilId = (int) $profil->id;
    }

    $apiKey = trim((string) ($this->option('api-key') ?: ''));
    if ($apiKey !== '') {
        AppSetting::query()->updateOrCreate(
            ['key' => 'attendance.public_sync.api_key'],
            ['value' => $apiKey]
        );
    }

    $existing = AttendanceExportJobModel::query()
        ->where('profil_bulan_id', $profilId)
        ->whereIn('status', ['pending', 'processing'])
        ->where('filters->job_type', 'sync_public_api')
        ->where('filters->start_date', $startDate->format('Y-m-d'))
        ->where('filters->end_date', $endDate->format('Y-m-d'))
        ->first();
    if ($existing) {
        $this->warn('Sync job masih aktif: ' . $existing->request_no . ' (ID=' . $existing->id . ')');
        return 0;
    }

    $job = AttendanceExportJobModel::query()->create([
        'request_no' => 'ARS-' . now()->format('YmdHis') . '-' . Str::upper(Str::random(4)),
        'user_id' => null,
        'profil_bulan_id' => $profilId,
        'filters' => [
            'job_type' => 'sync_public_api',
            'stage' => 'queued',
            'message' => 'Sync API dibuat oleh scheduler command.',
            'base_url' => trim((string) ($this->option('base-url') ?: '')),
            'api_key_set' => $apiKey !== '' || trim((string) (AppSetting::query()->where('key', 'attendance.public_sync.api_key')->value('value') ?: env('WDMS_PUBLIC_API_KEY', ''))) !== '',
            'start_date' => $startDate->format('Y-m-d'),
            'end_date' => $endDate->format('Y-m-d'),
            'sn' => trim((string) ($this->option('sn') ?: '')),
            'emp_code' => trim((string) ($this->option('emp-code') ?: '')),
            'limit' => max(1, min(1000, (int) ($this->option('limit') ?: 500))),
            'source' => trim((string) ($this->option('source') ?: 'wdms-public-api')) ?: 'wdms-public-api',
            'resolved_lock_override' => (bool) $this->option('override-lock'),
            'run_detect_after_sync' => (bool) $this->option('run-detect'),
            'fetched' => 0,
            'normalized' => 0,
            'inserted' => 0,
            'duplicates' => 0,
            'failed' => 0,
        ],
        'status' => 'pending',
        'total_users' => 100,
        'processed_users' => 0,
    ]);

    SyncPublicApiLogJob::dispatch((int) $job->id)->onQueue('attendance-review');
    $queueFallback = $processRealtimeQueuesOnce();

    $this->info('Sync attendance queued: ' . $job->request_no . ' (ID=' . $job->id . ')');
    $this->line('Range: ' . $startDate->format('Y-m-d') . ' s/d ' . $endDate->format('Y-m-d') . ' | Profil: ' . $profilId);
    if (($queueFallback['ran'] ?? false) === true) {
        $this->line('Queue realtime diproses sekali: ' . (int) ($queueFallback['pending'] ?? 0) . ' pending.');
    }
    return 0;
})->purpose('Queue sync attendance API harian/per jam ke Attendance Review');

Artisan::command('thirdparty:sync-suite
    {--only=masterjob,spl,leave,attendance : Komponen yang dijalankan, pisahkan dengan koma}
    {--date-from=2025-12-26 : Tanggal awal sinkron SPL/Leave}
    {--date-to= : Tanggal akhir sinkron SPL/Leave, default hari ini}
    {--rolling-days=0 : Jika >0, SPL/Leave ambil rolling N hari ke belakang sampai hari ini}
    {--attendance-mode=today : today atau yesterday untuk attendance:sync-daily}
    {--attendance-lookback=1 : Ambil ulang N hari ke belakang untuk attendance sync}
    {--attendance-profil-bulan-id= : Override profil bulan untuk attendance sync}
    {--attendance-base-url= : Override host API attendance}
    {--attendance-api-key= : Simpan/update token API attendance}
    {--attendance-limit=500 : Limit per halaman API attendance}
    {--attendance-source=wdms-public-api : Source log attendance}
    {--attendance-sn= : Filter SN/device attendance}
    {--attendance-emp-code= : Filter employee code attendance}
    {--attendance-run-detect : Jalankan detection setelah sync attendance}
    {--attendance-override-lock : Bypass lock ingest tanggal lama}
    {--spl-mode=draft : Mode sync SPL: draft, submit, atau approve}
    {--submit-spl : Setelah sync SPL, draft yang baru/masih draft langsung submit ke approval}
    {--approve-spl : Setelah sync SPL, submit lalu approve semua step via bypass admin/HRD}
    {--submit-leave : Setelah sync Leave, draft yang baru/masih draft langsung submit ke approval}
    {--leave-mode=draft : Mode sync Leave: draft, submit, atau approve}
    {--sync-leave-approval : Setelah sync leave, sinkronkan leave approved ke attendance review}
    {--dry-run : Simulasi saja untuk masterjob/spl/leave}
', function () {
    $dateFrom = trim((string) ($this->option('date-from') ?: '2025-12-26'));
    $dateTo = trim((string) ($this->option('date-to') ?: now()->toDateString()));
    $rollingDays = max(0, min(31, (int) ($this->option('rolling-days') ?: 0)));
    $attendanceMode = strtolower(trim((string) ($this->option('attendance-mode') ?: 'today')));
    $attendanceLookback = max(0, min(31, (int) ($this->option('attendance-lookback') ?: 1)));
    $attendanceProfilId = (int) ($this->option('attendance-profil-bulan-id') ?: 0);
    $attendanceBaseUrl = trim((string) ($this->option('attendance-base-url') ?: ''));
    $attendanceApiKey = trim((string) ($this->option('attendance-api-key') ?: ''));
    $attendanceLimit = max(1, min(2000, (int) ($this->option('attendance-limit') ?: 500)));
    $attendanceSource = trim((string) ($this->option('attendance-source') ?: 'wdms-public-api')) ?: 'wdms-public-api';
    $attendanceSn = trim((string) ($this->option('attendance-sn') ?: ''));
    $attendanceEmpCode = trim((string) ($this->option('attendance-emp-code') ?: ''));
    $attendanceRunDetect = (bool) $this->option('attendance-run-detect');
    $attendanceOverrideLock = (bool) $this->option('attendance-override-lock');
    $submitSpl = (bool) $this->option('submit-spl');
    $approveSpl = (bool) $this->option('approve-spl');
    $splMode = strtolower(trim((string) ($this->option('spl-mode') ?: 'draft')));
    $submitLeave = (bool) $this->option('submit-leave');
    $leaveMode = strtolower(trim((string) ($this->option('leave-mode') ?: 'draft')));
    if (!in_array($leaveMode, ['draft', 'submit', 'approve'], true)) {
        $this->error('leave-mode harus draft, submit, atau approve.');
        return 1;
    }
    $syncLeaveApproval = (bool) $this->option('sync-leave-approval');
    $dryRun = (bool) $this->option('dry-run');

    $onlyRaw = trim((string) ($this->option('only') ?: 'masterjob,spl,leave,attendance'));
    $allowed = ['masterjob', 'spl', 'leave', 'attendance'];
    $selected = collect(preg_split('/[,\s;]+/', $onlyRaw) ?: [])
        ->map(fn ($v) => strtolower(trim((string) $v)))
        ->filter(fn ($v) => $v !== '')
        ->intersect($allowed)
        ->values();

    if ($selected->isEmpty()) {
        $this->error('Option --only tidak valid. Gunakan kombinasi: ' . implode(', ', $allowed));
        return 1;
    }

    $progressContext = (array) config('scheduler.task_progress', []);
    $progressFile = trim((string) ($progressContext['file'] ?? ''));
    $progressComponentMeta = [
        'masterjob' => 'Master Job',
        'spl' => 'SPL',
        'leave' => 'Leave',
        'attendance' => 'Attendance',
    ];
    $progressState = [
        'task_id' => (int) ($progressContext['task_id'] ?? 0),
        'task_name' => (string) ($progressContext['task_name'] ?? ''),
        'status' => 'processing',
        'started_at' => now()->format('Y-m-d H:i:s'),
        'updated_at' => now()->format('Y-m-d H:i:s'),
        'selected' => $selected->values()->all(),
        'components' => collect($progressComponentMeta)->mapWithKeys(function ($label, $key) use ($selected) {
            $enabled = $selected->contains($key);
            return [$key => [
                'key' => $key,
                'label' => $label,
                'enabled' => $enabled,
                'status' => $enabled ? 'pending' : 'disabled',
                'percent' => $enabled ? 0 : null,
                'message' => $enabled ? 'Menunggu giliran...' : 'Tidak dipilih pada command ini.',
                'processed' => 0,
                'total' => 0,
                'stats' => [],
                'counts_label' => '',
                'output_summary' => '',
                'updated_at' => now()->format('Y-m-d H:i:s'),
            ]];
        })->all(),
    ];
    $compactOutput = function ($output): string {
        return collect(preg_split('/\r?\n/', trim((string) $output)) ?: [])
            ->map(fn ($line) => trim((string) $line))
            ->filter()
            ->take(6)
            ->implode("\n");
    };
    $recalculateProgressState = function () use (&$progressState): void {
        $enabledComponents = collect((array) ($progressState['components'] ?? []))
            ->filter(fn ($component) => (bool) ($component['enabled'] ?? false))
            ->values();
        if ($enabledComponents->isEmpty()) {
            $progressState['percent'] = 0;
            $progressState['status'] = 'idle';
            return;
        }

        $percent = round((float) $enabledComponents->avg(function ($component) {
            if (isset($component['percent']) && $component['percent'] !== null) {
                return (float) $component['percent'];
            }

            $status = strtolower((string) ($component['status'] ?? ''));
            return in_array($status, ['done', 'success'], true) ? 100 : 0;
        }), 2);
        $statuses = $enabledComponents
            ->map(fn ($component) => strtolower((string) ($component['status'] ?? 'pending')))
            ->all();

        if (collect($statuses)->contains(fn ($status) => $status === 'failed')) {
            $progressState['status'] = 'failed';
        } elseif (collect($statuses)->contains(fn ($status) => in_array($status, ['processing', 'pending', 'queued'], true))) {
            $progressState['status'] = 'processing';
        } else {
            $progressState['status'] = 'success';
        }

        $progressState['percent'] = max(0, min(100, $percent));
    };
    $writeProgressState = function () use (&$progressState, $progressFile, $recalculateProgressState): void {
        if ($progressFile === '') {
            return;
        }

        $recalculateProgressState();
        $progressState['updated_at'] = now()->format('Y-m-d H:i:s');
        \Illuminate\Support\Facades\File::ensureDirectoryExists(dirname($progressFile));
        \Illuminate\Support\Facades\File::put($progressFile, json_encode($progressState, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
    };
    $updateProgressComponent = function (string $key, array $changes = []) use (&$progressState, $writeProgressState): void {
        if (!isset($progressState['components'][$key]) || !is_array($progressState['components'][$key])) {
            return;
        }

        $current = $progressState['components'][$key];
        $progressState['components'][$key] = array_merge($current, $changes, [
            'updated_at' => now()->format('Y-m-d H:i:s'),
        ]);
        $writeProgressState();
    };
    $buildCountsLabel = function (array $stats, string $mode): string {
        return match ($mode) {
            'masterjob' => sprintf(
                'proc %d | create %d | update %d | fail %d',
                (int) ($stats['processed'] ?? 0),
                (int) ($stats['created'] ?? 0),
                (int) ($stats['updated'] ?? 0),
                (int) ($stats['failed'] ?? 0)
            ),
            'spl' => sprintf(
                'proc %d | create %d | update %d | fail %d',
                (int) ($stats['processed'] ?? 0),
                (int) ($stats['created'] ?? 0),
                (int) ($stats['updated'] ?? 0),
                (int) ($stats['failed'] ?? 0)
            ),
            'leave' => sprintf(
                'proc %d | create %d | update %d | fail %d',
                (int) ($stats['processed'] ?? 0),
                (int) ($stats['created'] ?? 0),
                (int) ($stats['updated'] ?? 0),
                (int) ($stats['failed'] ?? 0)
            ),
            default => '',
        };
    };
    $writeProgressState();

    if (!in_array($attendanceMode, ['today', 'yesterday'], true)) {
        $this->error('attendance-mode harus today atau yesterday.');
        return 1;
    }
    if (!in_array($splMode, ['draft', 'submit', 'approve'], true)) {
        $this->error('spl-mode harus draft, submit, atau approve.');
        return 1;
    }
    if ($approveSpl) {
        $splMode = 'approve';
    } elseif ($submitSpl && $splMode === 'draft') {
        $splMode = 'submit';
    }
    $autoSubmitSpl = in_array($splMode, ['submit', 'approve'], true);
    $autoApproveSpl = $splMode === 'approve';
    if ($submitLeave && $leaveMode === 'draft') {
        $leaveMode = 'submit';
    }
    $autoSubmitLeave = in_array($leaveMode, ['submit', 'approve'], true);
    $autoApproveLeave = $leaveMode === 'approve';

    try {
        if ($rollingDays > 0) {
            $dateTo = Carbon::today()->format('Y-m-d');
            $dateFrom = Carbon::today()->subDays($rollingDays)->format('Y-m-d');
        } else {
            $dateFrom = Carbon::parse($dateFrom)->format('Y-m-d');
            $dateTo = Carbon::parse($dateTo)->format('Y-m-d');
        }
    } catch (Throwable $e) {
        $this->error('Format date-from/date-to tidak valid. Gunakan YYYY-MM-DD.');
        return 1;
    }

    if (strcmp($dateTo, $dateFrom) < 0) {
        [$dateFrom, $dateTo] = [$dateTo, $dateFrom];
    }

    $this->info('Mulai cron sync suite...');
    $this->line('Komponen: ' . $selected->implode(', '));
    $this->line('Mode: ' . ($dryRun ? 'DRY-RUN' : 'APPLY'));
    $this->line('Range SPL/Leave: ' . $dateFrom . ' s/d ' . $dateTo);
    if ($selected->contains('spl')) {
        $this->line('Mode SPL: ' . strtoupper($splMode));
    }
    if ($rollingDays > 0) {
        $this->line('Mode rolling SPL/Leave: ' . $rollingDays . ' hari ke belakang');
    }

    $results = [];
    $hasFailure = false;

    if ($selected->contains('masterjob')) {
        $this->line('');
        $this->info('[1] Sync Master Job');
        $updateProgressComponent('masterjob', [
            'status' => 'processing',
            'percent' => 0,
            'message' => 'Mengambil data project dari database pihak ketiga...',
        ]);
        try {
            $result = app(ThirdPartyJobNumberSyncService::class)->sync([
                'connection' => 'third_party',
                'source_table' => 'master_project',
                'apply' => !$dryRun,
                'update_existing' => true,
                'no_create' => false,
                'chunk' => 500,
                'progress_callback' => function (array $progress) use ($updateProgressComponent, $buildCountsLabel) {
                    $stats = (array) ($progress['stats'] ?? []);
                    $updateProgressComponent('masterjob', [
                        'status' => 'processing',
                        'processed' => (int) ($progress['processed'] ?? 0),
                        'total' => (int) ($progress['total'] ?? 0),
                        'percent' => (float) ($progress['percent'] ?? 0),
                        'message' => (string) ($progress['message'] ?? 'Memproses master job...'),
                        'stats' => $stats,
                        'counts_label' => $buildCountsLabel($stats, 'masterjob'),
                    ]);
                },
            ]);
            $results['masterjob'] = $result;
            $ok = (bool) ($result['ok'] ?? false);
            $hasFailure = $hasFailure || !$ok;
            $stats = (array) ($result['stats'] ?? []);
            $updateProgressComponent('masterjob', [
                'status' => $ok ? 'success' : 'failed',
                'processed' => (int) ($stats['processed'] ?? 0),
                'total' => (int) (($result['meta']['will_process'] ?? 0)),
                'percent' => $ok ? 100 : (float) (($result['percent'] ?? 0)),
                'message' => (string) ($result['message'] ?? ($ok ? 'Sync master job selesai.' : 'Sync master job gagal.')),
                'stats' => $stats,
                'counts_label' => $buildCountsLabel($stats, 'masterjob'),
                'output_summary' => $compactOutput($result['output'] ?? ($result['message'] ?? '')),
            ]);
            $this->line((string) ($result['output'] ?? $result['message'] ?? '-'));
        } catch (Throwable $e) {
            $hasFailure = true;
            $results['masterjob'] = ['ok' => false, 'message' => $e->getMessage()];
            $updateProgressComponent('masterjob', [
                'status' => 'failed',
                'message' => 'Master job gagal: ' . $e->getMessage(),
                'output_summary' => $compactOutput($e->getMessage()),
            ]);
            $this->error('Master job gagal: ' . $e->getMessage());
        }
    }

    if ($selected->contains('spl')) {
        $this->line('');
        $this->info('[2] Sync SPL');
        $updateProgressComponent('spl', [
            'status' => 'processing',
            'percent' => 0,
            'message' => 'Mengambil data SPL dari ERP...',
        ]);
        try {
            $result = app(ThirdPartySplSyncService::class)->sync([
                'connection' => 'third_party',
                'source_table' => 'hrd_input_lembur',
                'date_from' => $dateFrom,
                'date_to' => $dateTo,
                'update_existing' => true,
                'apply' => !$dryRun,
                'auto_submit' => !$dryRun && $autoSubmitSpl,
                'auto_approve' => !$dryRun && $autoApproveSpl,
                'chunk' => 200,
                'progress_callback' => function (array $progress) use ($updateProgressComponent, $buildCountsLabel) {
                    $stats = (array) ($progress['stats'] ?? []);
                    $updateProgressComponent('spl', [
                        'status' => 'processing',
                        'processed' => (int) ($progress['processed'] ?? 0),
                        'total' => (int) ($progress['total'] ?? 0),
                        'percent' => (float) ($progress['percent'] ?? 0),
                        'message' => (string) ($progress['message'] ?? 'Memproses data SPL ke AHSP...'),
                        'stats' => $stats,
                        'counts_label' => $buildCountsLabel($stats, 'spl'),
                    ]);
                },
            ]);
            $results['spl'] = $result;
            $ok = (bool) ($result['ok'] ?? false);
            $hasFailure = $hasFailure || !$ok;
            $stats = (array) ($result['stats'] ?? []);
            $meta = (array) ($result['meta'] ?? []);
            $updateProgressComponent('spl', [
                'status' => $ok ? 'success' : 'failed',
                'processed' => (int) ($stats['processed'] ?? 0),
                'total' => (int) ($meta['source_rows'] ?? 0),
                'percent' => $ok ? 100 : (float) (($result['percent'] ?? 0)),
                'message' => (string) ($result['message'] ?? ($ok ? 'Sync SPL selesai.' : 'Sync SPL gagal.')),
                'stats' => $stats,
                'counts_label' => $buildCountsLabel($stats, 'spl'),
                'output_summary' => $compactOutput($result['output'] ?? ($result['message'] ?? '')),
            ]);
            $this->line((string) ($result['output'] ?? $result['message'] ?? '-'));
        } catch (Throwable $e) {
            $hasFailure = true;
            $results['spl'] = ['ok' => false, 'message' => $e->getMessage()];
            $updateProgressComponent('spl', [
                'status' => 'failed',
                'message' => 'SPL gagal: ' . $e->getMessage(),
                'output_summary' => $compactOutput($e->getMessage()),
            ]);
            $this->error('SPL gagal: ' . $e->getMessage());
        }
    }

    if ($selected->contains('leave')) {
        $this->line('');
        $this->info('[3] Sync Leave');
        $updateProgressComponent('leave', [
            'status' => 'processing',
            'percent' => 0,
            'message' => 'Mengambil data leave dari ERP...',
        ]);
        try {
            $result = app(ThirdPartyLeaveSyncService::class)->sync([
                'connection' => 'third_party',
                'source_table' => 'hrd_ijin_absensi',
                'employee_table' => 'hrd_master_pegawai',
                'date_column' => 'tanggal',
                'date_from' => $dateFrom,
                'date_to' => $dateTo,
                'update_existing' => true,
                'chunk' => 200,
                'apply' => !$dryRun,
                'auto_submit' => !$dryRun && $autoSubmitLeave,
                'auto_approve' => !$dryRun && $autoApproveLeave,
                'progress_callback' => function (array $progress) use ($updateProgressComponent, $buildCountsLabel) {
                    $stats = (array) ($progress['stats'] ?? []);
                    $updateProgressComponent('leave', [
                        'status' => 'processing',
                        'processed' => (int) ($progress['processed'] ?? 0),
                        'total' => (int) ($progress['total'] ?? 0),
                        'percent' => (float) ($progress['percent'] ?? 0),
                        'message' => (string) ($progress['message'] ?? 'Memproses sync leave...'),
                        'stats' => $stats,
                        'counts_label' => $buildCountsLabel($stats, 'leave'),
                    ]);
                },
            ]);
            $results['leave'] = $result;
            $ok = (bool) ($result['ok'] ?? false);
            $hasFailure = $hasFailure || !$ok;
            $stats = (array) ($result['stats'] ?? []);
            $meta = (array) ($result['meta'] ?? []);
            $updateProgressComponent('leave', [
                'status' => $ok ? 'success' : 'failed',
                'processed' => (int) ($stats['processed'] ?? 0),
                'total' => (int) ($meta['source_rows'] ?? 0),
                'percent' => $ok ? 100 : (float) (($result['percent'] ?? 0)),
                'message' => (string) ($result['message'] ?? ($ok ? 'Sync leave selesai.' : 'Sync leave gagal.')),
                'stats' => $stats,
                'counts_label' => $buildCountsLabel($stats, 'leave'),
                'output_summary' => $compactOutput($result['output'] ?? ($result['message'] ?? '')),
            ]);
            $this->line((string) ($result['output'] ?? $result['message'] ?? '-'));
        } catch (Throwable $e) {
            $hasFailure = true;
            $results['leave'] = ['ok' => false, 'message' => $e->getMessage()];
            $updateProgressComponent('leave', [
                'status' => 'failed',
                'message' => 'Leave gagal: ' . $e->getMessage(),
                'output_summary' => $compactOutput($e->getMessage()),
            ]);
            $this->error('Leave gagal: ' . $e->getMessage());
        }
    }

    if ($selected->contains('attendance')) {
        $this->line('');
        $this->info('[4] Sync Attendance Get Log');
        $updateProgressComponent('attendance', [
            'status' => 'processing',
            'percent' => 0,
            'message' => 'Mengirim job attendance ke queue...',
        ]);
        try {
            $beforeAttendanceJobId = \App\Modules\AttendanceReview\Models\AttendanceExportJobModel::query()->max('id') ?: 0;
            $params = [
                '--mode' => $attendanceMode,
                '--lookback-days' => $attendanceLookback,
                '--limit' => $attendanceLimit,
                '--source' => $attendanceSource,
            ];
            if ($attendanceProfilId > 0) {
                $params['--profil_bulan_id'] = $attendanceProfilId;
            }
            if ($attendanceBaseUrl !== '') {
                $params['--base-url'] = $attendanceBaseUrl;
            }
            if ($attendanceApiKey !== '') {
                $params['--api-key'] = $attendanceApiKey;
            }
            if ($attendanceSn !== '') {
                $params['--sn'] = $attendanceSn;
            }
            if ($attendanceEmpCode !== '') {
                $params['--emp-code'] = $attendanceEmpCode;
            }
            if ($attendanceRunDetect) {
                $params['--run-detect'] = true;
            }
            if ($attendanceOverrideLock) {
                $params['--override-lock'] = true;
            }

            $exitCode = Artisan::call('attendance:sync-daily', $params);
            $output = trim((string) Artisan::output());
            $ok = $exitCode === 0;
            $attendanceJob = \App\Modules\AttendanceReview\Models\AttendanceExportJobModel::query()
                ->where('id', '>', $beforeAttendanceJobId)
                ->where('filters->job_type', 'sync_public_api')
                ->orderByDesc('id')
                ->first();
            if (!$attendanceJob) {
                $attendanceJob = \App\Modules\AttendanceReview\Models\AttendanceExportJobModel::query()
                    ->where('filters->job_type', 'sync_public_api')
                    ->orderByDesc('id')
                    ->first();
            }
            $attendanceFilters = (array) ($attendanceJob?->filters ?? []);
            $attendanceTotal = max(0, (int) ($attendanceJob?->total_users ?? 0));
            $attendanceProcessed = max(0, (int) ($attendanceJob?->processed_users ?? 0));
            $attendancePercent = $attendanceTotal > 0
                ? round(($attendanceProcessed / max(1, $attendanceTotal)) * 100, 2)
                : 0;
            $results['attendance'] = [
                'ok' => $ok,
                'message' => $ok ? 'Sync attendance queued.' : 'Sync attendance gagal.',
                'output' => $output,
            ];
            $hasFailure = $hasFailure || !$ok;
            $updateProgressComponent('attendance', [
                'status' => $ok ? (string) ($attendanceJob?->status ?? 'queued') : 'failed',
                'processed' => $attendanceProcessed,
                'total' => $attendanceTotal,
                'percent' => $attendancePercent,
                'message' => $ok
                    ? (string) ($attendanceFilters['message'] ?? ($attendanceJob ? 'Job attendance berhasil dibuat.' : 'Sync attendance queued.'))
                    : 'Sync attendance gagal.',
                'attendance_job_id' => (int) ($attendanceJob?->id ?? 0),
                'request_no' => (string) ($attendanceJob?->request_no ?? ''),
                'counts_label' => sprintf(
                    'fetch %d | insert %d | dup %d | fail %d',
                    (int) ($attendanceFilters['fetched'] ?? 0),
                    (int) ($attendanceFilters['inserted'] ?? 0),
                    (int) ($attendanceFilters['duplicates'] ?? 0),
                    (int) ($attendanceFilters['failed'] ?? 0)
                ),
                'output_summary' => $compactOutput($output),
            ]);
            if ($output !== '') {
                $this->line($output);
            }
        } catch (Throwable $e) {
            $hasFailure = true;
            $results['attendance'] = ['ok' => false, 'message' => $e->getMessage()];
            $updateProgressComponent('attendance', [
                'status' => 'failed',
                'message' => 'Attendance gagal: ' . $e->getMessage(),
                'output_summary' => $compactOutput($e->getMessage()),
            ]);
            $this->error('Attendance get log gagal: ' . $e->getMessage());
        }
    }

    if ($syncLeaveApproval && !$dryRun) {
        $this->line('');
        $this->info('[5] Sync Leave Approved ke Attendance Review');
        try {
            $exitCode = Artisan::call('attendance:sync-leave-approval', [
                '--from' => $dateFrom,
                '--to' => $dateTo,
            ]);
            $output = trim((string) Artisan::output());
            $ok = $exitCode === 0;
            $results['leave_approval'] = [
                'ok' => $ok,
                'message' => $ok ? 'Sync leave approval selesai.' : 'Sync leave approval gagal.',
                'output' => $output,
            ];
            $hasFailure = $hasFailure || !$ok;
            if ($output !== '') {
                $this->line($output);
            }
        } catch (Throwable $e) {
            $hasFailure = true;
            $results['leave_approval'] = ['ok' => false, 'message' => $e->getMessage()];
            $this->error('Sync leave approval gagal: ' . $e->getMessage());
        }
    }

    $this->line('');
    $this->info('Cron sync suite selesai.');
    $progressState['finished_at'] = now()->format('Y-m-d H:i:s');
    $writeProgressState();

    return $hasFailure ? 1 : 0;
})->purpose('Jalankan cron sinkronisasi master job, SPL, leave, dan attendance get log dalam satu command');

Artisan::command('ahsp:cron-sync
    {--only=masterjob,spl,leave,attendance : Komponen sync, pisahkan koma}
    {--rolling-days=3 : Range rolling SPL/Leave dari hari ini ke belakang}
    {--attendance-lookback=3 : Range rolling attendance dari hari ini ke belakang}
    {--run-detect : Jalankan detection setelah attendance sync}
    {--override-lock : Bypass lock ingest attendance lama}
    {--spl-mode=draft : Mode sync SPL: draft, submit, atau approve}
    {--submit-spl : Draft SPL hasil sync langsung submit ke approval}
    {--approve-spl : Draft SPL hasil sync langsung submit dan approve via bypass admin/HRD}
    {--submit-leave : Draft Leave hasil sync langsung submit ke approval}
    {--leave-mode=draft : Mode sync Leave: draft, submit, atau approve}
    {--sync-leave-approval : Sync leave approved ke attendance review}', function () {
    $only = trim((string) ($this->option('only') ?: 'masterjob,spl,leave,attendance'));
    $rollingDays = max(0, min(31, (int) ($this->option('rolling-days') ?: 3)));
    $attendanceLookback = max(0, min(31, (int) ($this->option('attendance-lookback') ?: 3)));
    $splMode = strtolower(trim((string) ($this->option('spl-mode') ?: 'draft')));
    if (!in_array($splMode, ['draft', 'submit', 'approve'], true)) {
        $this->error('spl-mode harus draft, submit, atau approve.');
        return 1;
    }

    $leaveMode = strtolower(trim((string) ($this->option('leave-mode') ?: 'draft')));
    if (!in_array($leaveMode, ['draft', 'submit', 'approve'], true)) {
        $this->error('leave-mode harus draft, submit, atau approve.');
        return 1;
    }

    $params = [
        '--only' => $only,
        '--rolling-days' => $rollingDays,
        '--attendance-mode' => 'today',
        '--attendance-lookback' => $attendanceLookback,
        '--spl-mode' => $splMode,
        '--leave-mode' => $leaveMode,
    ];
    if ((bool) $this->option('run-detect')) {
        $params['--attendance-run-detect'] = true;
    }
    if ((bool) $this->option('override-lock')) {
        $params['--attendance-override-lock'] = true;
    }
    if ((bool) $this->option('submit-spl')) {
        $params['--submit-spl'] = true;
    }
    if ((bool) $this->option('approve-spl')) {
        $params['--approve-spl'] = true;
    }
    if ((bool) $this->option('submit-leave')) {
        $params['--submit-leave'] = true;
    }
    if ((bool) $this->option('sync-leave-approval')) {
        $params['--sync-leave-approval'] = true;
    }

    $this->info('Menjalankan AHSP cron sync...');
    $this->line('Command internal: thirdparty:sync-suite');
    $this->line('Only: ' . $only);

    $exitCode = Artisan::call('thirdparty:sync-suite', $params);
    $output = trim((string) Artisan::output());
    if ($output !== '') {
        $this->line($output);
    }

    return $exitCode;
})->purpose('Alias pendek cron AHSP untuk scheduler UI agar command tidak terlalu panjang');

Artisan::command('backup:project {--name=} {--include-vendor} {--include-database} {--database-only} {--dump-compat=} {--progress-file=}', function (ProjectBackupService $service) {
    $progressFile = (string) $this->option('progress-file');
    if ($progressFile === '') {
        $this->error('Progress file is required.');
        return 1;
    }

    try {
        $service->run([
            'name' => (string) $this->option('name'),
            'include_vendor' => (bool) $this->option('include-vendor'),
            'include_database' => (bool) $this->option('include-database'),
            'database_only' => (bool) $this->option('database-only'),
            'dump_compat' => (string) ($this->option('dump-compat') ?: 'auto'),
        ], $progressFile);
    } catch (\Throwable $e) {
        $payload = [
            'status' => 'error',
            'percent' => 100,
            'message' => $e->getMessage(),
        ];
        \Illuminate\Support\Facades\File::ensureDirectoryExists(dirname($progressFile));
        \Illuminate\Support\Facades\File::put($progressFile, json_encode($payload, JSON_PRETTY_PRINT));
        $this->error($e->getMessage());
        return 1;
    }

    $this->info('Backup completed.');
    return 0;
})->purpose('Run project backup in background with progress file');

Artisan::command('rbac:repair', function () {
    if (!Schema::hasTable('menus') || !Schema::hasTable('permissions')) {
        $this->error('Required tables not found (menus / permissions).');
        return 1;
    }

    $actions = config('rbac.permission_actions', ['view', 'create', 'edit', 'delete']);
    $menus = MenuModels::query()->get(['id', 'name', 'slug', 'department']);

    $createdPermissions = 0;
    $linkedMenuPermissions = 0;

    foreach ($menus as $menu) {
        foreach ($actions as $action) {
            $actionKey = is_string($action) ? $action : (is_int($action) ? (string) $action : '');
            $actionKey = trim(strtolower($actionKey));
            if ($actionKey === '') {
                continue;
            }

            $permSlug = $menu->slug . '.' . $actionKey;
            $permission = PermissionModels::firstOrCreate(
                ['slug' => $permSlug],
                [
                    'name' => $menu->name . ' ' . ($actionKey === 'view' ? 'Akses' : ucfirst($actionKey)),
                    'description' => trim(($menu->department ? $menu->department . ' ' : '') . $menu->name . ' ' . $actionKey),
                ]
            );
            if ($permission->wasRecentlyCreated) {
                $createdPermissions++;
            }

            if (Schema::hasTable('menu_permissions')) {
                $exists = \DB::table('menu_permissions')
                    ->where('menu_id', $menu->id)
                    ->where('permission_id', $permission->id)
                    ->exists();
                if (!$exists) {
                    \DB::table('menu_permissions')->insert([
                        'menu_id' => $menu->id,
                        'permission_id' => $permission->id,
                        'created_at' => now(),
                        'updated_at' => now(),
                    ]);
                    $linkedMenuPermissions++;
                }
            }
        }
    }

    $this->info("RBAC repair done. New permissions: {$createdPermissions}, menu_permissions linked: {$linkedMenuPermissions}.");
    return 0;
})->purpose('Repair missing permissions and menu_permissions links');

Artisan::command('leave:cleanup-holiday-weekend {--apply : Apply update status rejected for invalid leave}', function () {
    $rows = LeaveRequestModel::query()
        ->whereNotIn('status', ['rejected', 'cancelled'])
        ->orderBy('id')
        ->get(['id', 'request_no', 'karyawan_id', 'start_date', 'end_date', 'status', 'reason', 'approval_request_id']);

    if ($rows->isEmpty()) {
        $this->info('No leave request data found.');
        return 0;
    }

    $minDate = optional($rows->min('start_date'))?->format('Y-m-d');
    $maxDate = optional($rows->max('end_date'))?->format('Y-m-d');
    if (!$minDate || !$maxDate) {
        $this->info('No valid date range in leave requests.');
        return 0;
    }

    $holidayMap = [];
    $events = KalenderModel::query()
        ->where('is_active', true)
        ->where('event_type', 'holiday')
        ->whereDate('start_date', '<=', $maxDate)
        ->whereDate('end_date', '>=', $minDate)
        ->get(['name', 'start_date', 'end_date']);
    foreach ($events as $event) {
        $name = trim((string) ($event->name ?? 'Holiday'));
        $start = Carbon::parse((string) $event->start_date)->startOfDay();
        $end = Carbon::parse((string) $event->end_date)->startOfDay();
        if ($end->lt($start)) {
            continue;
        }
        foreach (CarbonPeriod::create($start->copy(), $end->copy()) as $d) {
            $holidayMap[$d->format('Y-m-d')] = $name;
        }
    }

    $invalid = collect();
    foreach ($rows as $row) {
        $startDate = $row->start_date ? Carbon::parse((string) $row->start_date)->startOfDay() : null;
        $endDate = $row->end_date ? Carbon::parse((string) $row->end_date)->startOfDay() : null;
        if (!$startDate || !$endDate || $endDate->lt($startDate)) {
            continue;
        }

        $hitDate = null;
        $hitKind = null;
        foreach (CarbonPeriod::create($startDate->copy(), $endDate->copy()) as $d) {
            $dateKey = $d->format('Y-m-d');
            if (isset($holidayMap[$dateKey])) {
                $hitDate = $dateKey;
                $hitKind = 'Holiday: ' . $holidayMap[$dateKey];
                break;
            }
            if (in_array((int) $d->dayOfWeekIso, [6, 7], true)) {
                $hitDate = $dateKey;
                $hitKind = 'Weekend';
                break;
            }
        }
        if ($hitDate === null) {
            continue;
        }
        $invalid->push([
            'id' => (int) $row->id,
            'request_no' => (string) ($row->request_no ?? '-'),
            'status' => (string) ($row->status ?? '-'),
            'hit_date' => $hitDate,
            'hit_kind' => (string) $hitKind,
        ]);
    }

    if ($invalid->isEmpty()) {
        $this->info('No invalid leave request on holiday/weekend.');
        return 0;
    }

    $this->warn('Invalid leave requests found: ' . $invalid->count());
    foreach ($invalid as $i) {
        $this->line("#{$i['id']} {$i['request_no']} | status={$i['status']} | {$i['hit_date']} {$i['hit_kind']}");
    }

    if (!(bool) $this->option('apply')) {
        $this->info('Dry-run only. Use --apply to update status.');
        return 0;
    }

    $updated = 0;
    \DB::transaction(function () use ($invalid, &$updated): void {
        foreach ($invalid as $i) {
            /** @var LeaveRequestModel|null $row */
            $row = LeaveRequestModel::query()->find($i['id']);
            if (!$row) {
                continue;
            }
            if ($row->approval_request_id) {
                \DB::table('approval_requests')->where('id', (int) $row->approval_request_id)->delete();
            }
            $prefix = '[AUTO-INVALID-HOLIDAY/WEEKEND] ';
            $oldReason = trim((string) ($row->reason ?? ''));
            $msg = "Ditolak otomatis karena tanggal {$i['hit_date']} termasuk {$i['hit_kind']}.";
            $row->reason = $prefix . $msg . ($oldReason !== '' ? (' | ' . $oldReason) : '');
            $row->status = 'rejected';
            $row->approval_request_id = null;
            $row->save();
            $updated++;
        }
    });

    $this->info("Cleanup applied. Updated: {$updated}");
    return 0;
})->purpose('Cleanup leave request that falls on holiday/weekend (strict policy)');

Artisan::command('leave:import-izin-report
    {file : Path file report .xls/.html}
    {--dry-run : Parse dan mapping saja tanpa insert}
    {--created-by= : User ID pembuat request (default 1)}
', function () {
    $file = (string) $this->argument('file');
    if ($file === '' || !is_file($file)) {
        $this->error('File tidak ditemukan: ' . $file);
        return 1;
    }

    $raw = @file_get_contents($file);
    if ($raw === false || trim($raw) === '') {
        $this->error('File tidak bisa dibaca / kosong.');
        return 1;
    }

    $normalizeName = static function (string $name): string {
        $s = Str::ascii(mb_strtolower(trim($name), 'UTF-8'));
        $s = preg_replace('/[^a-z0-9]+/u', ' ', $s) ?: '';
        return trim(preg_replace('/\s+/u', ' ', $s) ?: '');
    };
    $parseDateHeader = static function (string $label): ?Carbon {
        $clean = trim(html_entity_decode(strip_tags($label), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
        $clean = preg_replace('/\s+/', ' ', $clean) ?: '';
        if ($clean === '') {
            return null;
        }
        if (str_contains($clean, '-')) {
            $parts = array_map('trim', explode('-', $clean, 2));
            $clean = trim((string) ($parts[1] ?? $parts[0]));
        }
        $months = [
            'jan' => 'Jan', 'feb' => 'Feb', 'mar' => 'Mar', 'apr' => 'Apr', 'mei' => 'May', 'may' => 'May', 'jun' => 'Jun',
            'jul' => 'Jul', 'agu' => 'Aug', 'aug' => 'Aug', 'sep' => 'Sep', 'okt' => 'Oct', 'oct' => 'Oct', 'nov' => 'Nov', 'des' => 'Dec', 'dec' => 'Dec',
        ];
        $tmp = $clean;
        foreach ($months as $k => $v) {
            $tmp = preg_replace('/\b' . preg_quote($k, '/') . '\b/i', $v, $tmp) ?: $tmp;
        }
        try {
            return Carbon::createFromFormat('j M y', $tmp)->startOfDay();
        } catch (\Throwable) {
            return null;
        }
    };
    $mapLeaveType = static function (string $value): ?string {
        $v = trim(mb_strtolower($value, 'UTF-8'));
        if ($v === '' || $v === '-' || $v === '0') {
            return null;
        }
        if (str_contains($v, 'cuti')) {
            return 'annual';
        }
        if (str_contains($v, 'sakit') || $v === 'sd') {
            return 'sakit';
        }
        if (str_contains($v, 'dinas')) {
            return 'dinas';
        }
        if (str_contains($v, 'mati') || str_contains($v, 'duka')) {
            return 'izin';
        }
        if (str_contains($v, 'izin') || str_contains($v, 'ijin')) {
            return 'izin';
        }
        return preg_replace('/\s+/', '_', $v) ?: null;
    };

    libxml_use_internal_errors(true);
    $dom = new \DOMDocument();
    $ok = $dom->loadHTML('<?xml encoding="utf-8" ?>' . $raw);
    if (!$ok) {
        $this->error('Format file tidak terbaca sebagai HTML table.');
        return 1;
    }
    $xp = new \DOMXPath($dom);
    $table = $xp->query('//table')->item(0);
    if (!$table) {
        $this->error('Tag <table> tidak ditemukan.');
        return 1;
    }

    $headerCells = $xp->query('.//thead/tr[1]/th', $table);
    if (!$headerCells || $headerCells->length < 3) {
        $this->error('Header kolom tidak valid.');
        return 1;
    }
    $dateCols = [];
    for ($i = 2; $i < $headerCells->length; $i++) {
        $label = trim((string) $headerCells->item($i)?->textContent);
        $date = $parseDateHeader($label);
        if ($date) {
            $dateCols[$i] = $date;
        }
    }
    if (empty($dateCols)) {
        $this->error('Kolom tanggal tidak terdeteksi.');
        return 1;
    }

    $karyawans = KaryawanModel::query()
        ->select(['id', 'employee_code', 'full_name', 'is_active'])
        ->get();
    $nameMap = [];
    foreach ($karyawans as $k) {
        $key = $normalizeName((string) $k->full_name);
        if ($key === '') {
            continue;
        }
        $nameMap[$key] = $nameMap[$key] ?? [];
        $nameMap[$key][] = $k;
    }

    $rows = $xp->query('.//tbody/tr', $table);
    $matchedEmployees = 0;
    $ambiguousEmployees = [];
    $unmatchedEmployees = [];
    $eventsByEmployee = [];

    foreach ($rows as $tr) {
        $cells = $xp->query('./td', $tr);
        if (!$cells || $cells->length < 3) {
            continue;
        }
        $name = trim((string) $cells->item(1)?->textContent);
        if ($name === '') {
            continue;
        }
        $normalized = $normalizeName($name);
        if ($normalized === '') {
            $unmatchedEmployees[] = $name;
            continue;
        }

        $candidates = $nameMap[$normalized] ?? [];
        if (count($candidates) !== 1) {
            if (count($candidates) === 0) {
                $partial = collect($nameMap)
                    ->filter(fn ($_, $k) => str_contains($k, $normalized) || str_contains($normalized, $k))
                    ->flatten(1)
                    ->unique('id')
                    ->values();
                if ($partial->count() === 1) {
                    $candidates = [$partial->first()];
                } elseif ($partial->count() > 1) {
                    $ambiguousEmployees[] = $name . ' => ' . $partial->pluck('full_name')->implode(' | ');
                    continue;
                } else {
                    $unmatchedEmployees[] = $name;
                    continue;
                }
            } else {
                $ambiguousEmployees[] = $name . ' => ' . collect($candidates)->pluck('full_name')->implode(' | ');
                continue;
            }
        }

        /** @var KaryawanModel $emp */
        $emp = $candidates[0];
        $matchedEmployees++;
        $empId = (int) $emp->id;
        $eventsByEmployee[$empId] = $eventsByEmployee[$empId] ?? [];

        for ($i = 2; $i < $cells->length; $i++) {
            if (!isset($dateCols[$i])) {
                continue;
            }
            $rawType = trim((string) $cells->item($i)?->textContent);
            $leaveType = $mapLeaveType($rawType);
            if (!$leaveType) {
                continue;
            }
            $eventsByEmployee[$empId][] = [
                'date' => $dateCols[$i]->copy(),
                'leave_type' => $leaveType,
                'raw_type' => $rawType,
                'source_name' => $name,
                'employee_name' => (string) $emp->full_name,
                'employee_code' => (string) $emp->employee_code,
            ];
        }
    }

    $ranges = [];
    foreach ($eventsByEmployee as $empId => $events) {
        usort($events, static function (array $a, array $b): int {
            if ($a['leave_type'] !== $b['leave_type']) {
                return strcmp($a['leave_type'], $b['leave_type']);
            }
            return $a['date']->getTimestamp() <=> $b['date']->getTimestamp();
        });
        $current = null;
        foreach ($events as $event) {
            if ($current === null) {
                $current = [
                    'karyawan_id' => $empId,
                    'leave_type' => $event['leave_type'],
                    'start_date' => $event['date']->copy(),
                    'end_date' => $event['date']->copy(),
                    'raw_type' => $event['raw_type'],
                    'employee_name' => $event['employee_name'],
                    'employee_code' => $event['employee_code'],
                ];
                continue;
            }

            $sameType = $current['leave_type'] === $event['leave_type'];
            $expectedNext = $current['end_date']->copy()->addDay()->format('Y-m-d');
            if ($sameType && $expectedNext === $event['date']->format('Y-m-d')) {
                $current['end_date'] = $event['date']->copy();
            } else {
                $ranges[] = $current;
                $current = [
                    'karyawan_id' => $empId,
                    'leave_type' => $event['leave_type'],
                    'start_date' => $event['date']->copy(),
                    'end_date' => $event['date']->copy(),
                    'raw_type' => $event['raw_type'],
                    'employee_name' => $event['employee_name'],
                    'employee_code' => $event['employee_code'],
                ];
            }
        }
        if ($current !== null) {
            $ranges[] = $current;
        }
    }

    $this->line('Matched nama karyawan: ' . $matchedEmployees);
    $this->line('Total pengajuan (range): ' . count($ranges));
    $this->line('Ambiguous nama: ' . count($ambiguousEmployees));
    $this->line('Unmatched nama: ' . count($unmatchedEmployees));

    if (!empty($ambiguousEmployees)) {
        $this->warn('Contoh ambiguous:');
        foreach (array_slice($ambiguousEmployees, 0, 15) as $v) {
            $this->line(' - ' . $v);
        }
    }
    if (!empty($unmatchedEmployees)) {
        $this->warn('Contoh unmatched:');
        foreach (array_slice(array_unique($unmatchedEmployees), 0, 15) as $v) {
            $this->line(' - ' . $v);
        }
    }

    if ((bool) $this->option('dry-run')) {
        $this->info('Dry-run selesai. Tidak ada insert.');
        return 0;
    }

    $createdBy = (int) ($this->option('created-by') ?: 1);
    $inserted = 0;
    $skippedDuplicate = 0;
    \DB::transaction(function () use ($ranges, $createdBy, &$inserted, &$skippedDuplicate): void {
        foreach ($ranges as $r) {
            $start = $r['start_date']->format('Y-m-d');
            $end = $r['end_date']->format('Y-m-d');
            $exists = LeaveRequestModel::query()
                ->where('karyawan_id', (int) $r['karyawan_id'])
                ->whereRaw('LOWER(leave_type) = ?', [strtolower((string) $r['leave_type'])])
                ->whereDate('start_date', $start)
                ->whereDate('end_date', $end)
                ->exists();
            if ($exists) {
                $skippedDuplicate++;
                continue;
            }

            $days = max(1, $r['start_date']->diffInDays($r['end_date']) + 1);
            $dateToken = now()->format('Ymd');
            $requestNo = 'LR-IMP-' . $dateToken . '-' . str_pad((string) (LeaveRequestModel::max('id') + 1), 6, '0', STR_PAD_LEFT);
            LeaveRequestModel::query()->create([
                'request_no' => $requestNo,
                'karyawan_id' => (int) $r['karyawan_id'],
                'approval_flow_id' => null,
                'approval_request_id' => null,
                'leave_type' => (string) $r['leave_type'],
                'start_date' => $start,
                'end_date' => $end,
                'total_days' => (float) $days,
                'reason' => 'Import izin absensi (nama match) | source: ' . ($r['raw_type'] ?: '-'),
                'status' => 'approved',
                'created_by' => $createdBy > 0 ? $createdBy : null,
                'is_active' => true,
            ]);
            $inserted++;
        }
    });

    $this->info("Insert selesai. Inserted={$inserted}, skipped_duplicate={$skippedDuplicate}, ambiguous=" . count($ambiguousEmployees) . ", unmatched=" . count($unmatchedEmployees));
    return 0;
})->purpose('Import report izin absensi (xls/html) tanpa finger_id, mapping via nama karyawan');

Artisan::command('attendance:sync-leave-approval
    {--karyawan_id= : Filter 1 karyawan_id}
    {--from= : Tanggal mulai YYYY-MM-DD}
    {--to= : Tanggal akhir YYYY-MM-DD}
    {--batch=200 : Jumlah data leave per batch}
    {--dry-run : Simulasi tanpa update}
', function () {
    $batch = max(1, (int) $this->option('batch'));
    $karyawanId = (int) ($this->option('karyawan_id') ?: 0);

    $from = null;
    $to = null;
    try {
        if ($this->option('from')) {
            $from = Carbon::parse((string) $this->option('from'))->startOfDay();
        }
        if ($this->option('to')) {
            $to = Carbon::parse((string) $this->option('to'))->startOfDay();
        }
    } catch (\Throwable $e) {
        $this->error('Format tanggal tidak valid. Gunakan YYYY-MM-DD.');
        return 1;
    }
    if ($from && $to && $to->lt($from)) {
        $this->error('Option --to tidak boleh lebih kecil dari --from.');
        return 1;
    }

    $q = LeaveRequestModel::query()
        ->where('status', 'approved')
        ->orderBy('id');

    if ($karyawanId > 0) {
        $q->where('karyawan_id', $karyawanId);
    }
    if ($from) {
        $q->whereDate('end_date', '>=', $from->format('Y-m-d'));
    }
    if ($to) {
        $q->whereDate('start_date', '<=', $to->format('Y-m-d'));
    }

    $totalLeave = (clone $q)->count();
    if ($totalLeave <= 0) {
        $this->info('Tidak ada leave approved yang cocok filter.');
        return 0;
    }

    $dryRun = (bool) $this->option('dry-run');
    $this->info('Mulai sync leave approval...');
    $this->line('Total leave candidate: ' . $totalLeave);
    $this->line('Mode: ' . ($dryRun ? 'DRY RUN' : 'APPLY'));

    $processed = 0;
    $updatedReviews = 0;
    $affectedLeaves = 0;

    $q->chunkById($batch, function ($rows) use (&$processed, &$updatedReviews, &$affectedLeaves, $dryRun, $from, $to) {
        foreach ($rows as $row) {
            $processed++;
            $start = Carbon::parse((string) $row->start_date)->startOfDay();
            $end = Carbon::parse((string) $row->end_date)->startOfDay();
            if ($end->lt($start)) {
                $end = $start->copy();
            }
            if ($from && $start->lt($from)) {
                $start = $from->copy();
            }
            if ($to && $end->gt($to)) {
                $end = $to->copy();
            }
            if ($end->lt($start)) {
                continue;
            }

            $reviewQ = AttendanceDailyReviewModel::query()
                ->where('karyawan_id', (int) $row->karyawan_id)
                ->whereDate('work_date', '>=', $start->format('Y-m-d'))
                ->whereDate('work_date', '<=', $end->format('Y-m-d'))
                ->where('is_approved', false);

            $count = (int) (clone $reviewQ)->count();
            if ($count <= 0) {
                continue;
            }

            if (!$dryRun) {
                $reviewQ->update([
                    'is_approved' => true,
                    'approved_at' => now(),
                ]);
            }

            $updatedReviews += $count;
            $affectedLeaves++;
        }
    });

    $this->info('Selesai.');
    $this->line('Processed leaves: ' . $processed);
    $this->line('Affected leaves: ' . $affectedLeaves);
    $this->line(($dryRun ? 'Would update reviews: ' : 'Updated reviews: ') . $updatedReviews);
    return 0;
})->purpose('Sync status approved leave ke attendance_daily_reviews.is_approved');

Artisan::command('bpjs:apply-jkk-rule
    {--apply : Benar-benar update MySQL}
    {--effective-start=2026-03-26 : Tanggal efektif profile BPJS}
    {--include-inactive : Ikut update karyawan nonaktif}
', function () {
    $exceptionCodes = [
        '41347', '41396', '41400', '30029', '30035', '30023', '30018', '30009', '10123', '42427',
        '42140', '40717', '41410', '42555', '42143', '41346', '42163', '41168', '1848', '40121',
        '42445', '40484', '30085', '42597', '41391', '41303', '42822', '42437', '42904', '41187',
        '30077', '41319', '30047', '41139', '42447', '41431', '41408', '42780', '42877', '42435',
        '42793', '30151', '42894', '42901', '42889', '40116', '42933', '42814', '42863', '42895',
        '42696', '40113', '42685', '41719', '42665', '41600', '41485', '30134', '41364', '41503',
        '41331', '42682', '41422', '41426', '41637', '41487', '41638', '41580', '42683', '42465',
        '42898', '42663', '42900', '42674', '42673', '42675', '42670', '42849', '42848', '42881',
        '42890', '42893', '42892', '42905', '41622', '42740', '41507', '42584', '42501', '41796',
        '42246', '41447', '30124', '42557', '30127', '41444', '20005', '41544', '42407', '41506',
        '42752', '42734', '30049', '42686', '42688', '42406', '30065', '41720', '42442', '41535',
        '42458', '41766', '1419', '1195',
    ];
    $exceptionMap = array_fill_keys($exceptionCodes, true);

    foreach (['master_bpjs', 'master_karyawans', 'master_karyawan_bpjs_profiles', 'master_karyawan_bpjs_profile_components'] as $table) {
        if (!Schema::hasTable($table)) {
            $this->error("Tabel {$table} belum tersedia.");
            return 1;
        }
    }

    $effectiveStart = Carbon::parse((string) $this->option('effective-start'))->format('Y-m-d');
    $apply = (bool) $this->option('apply');

    $jkk = DB::table('master_bpjs')
        ->where(function ($q) {
            $q->whereRaw('UPPER(COALESCE(component_code, "")) = ?', ['JKK'])
                ->orWhereRaw('UPPER(COALESCE(code, "")) LIKE ?', ['%JKK%']);
        })
        ->where('is_active', true)
        ->orderBy('display_order')
        ->orderBy('id')
        ->first(['id', 'code', 'name', 'component_code']);

    if (!$jkk) {
        $this->error('Komponen BPJS JKK aktif tidak ditemukan di master_bpjs.');
        return 1;
    }

    $columns = ['id', 'employee_code', 'full_name'];
    $hasFingerId = Schema::hasColumn('master_karyawans', 'finger_id');
    $hasIsActive = Schema::hasColumn('master_karyawans', 'is_active');
    if ($hasFingerId) {
        $columns[] = 'finger_id';
    }
    if ($hasIsActive) {
        $columns[] = 'is_active';
    }

    $employeesQuery = DB::table('master_karyawans')->select($columns)->orderBy('employee_code');
    if ($hasIsActive && !(bool) $this->option('include-inactive')) {
        $employeesQuery->where('is_active', true);
    }
    $employees = $employeesQuery->get();

    $matchedExceptionCodes = [];
    $willEnroll = 0;
    $willExclude = 0;

    foreach ($employees as $employee) {
        $code = trim((string) ($employee->employee_code ?? ''));
        $finger = $hasFingerId ? trim((string) ($employee->finger_id ?? '')) : '';
        $isException = ($code !== '' && isset($exceptionMap[$code])) || ($finger !== '' && isset($exceptionMap[$finger]));
        if ($isException) {
            if ($code !== '' && isset($exceptionMap[$code])) {
                $matchedExceptionCodes[$code] = true;
            }
            if ($finger !== '' && isset($exceptionMap[$finger])) {
                $matchedExceptionCodes[$finger] = true;
            }
            $willExclude++;
        } else {
            $willEnroll++;
        }
    }

    $missingExceptions = array_values(array_diff($exceptionCodes, array_keys($matchedExceptionCodes)));

    $this->line('Komponen: ' . ($jkk->component_code ?: $jkk->code) . ' - ' . $jkk->name . ' (ID ' . $jkk->id . ')');
    $this->line('Tanggal efektif: ' . $effectiveStart);
    $this->line('Karyawan diproses: ' . $employees->count());
    $this->line('Akan enrolled JKK: ' . $willEnroll);
    $this->line('Akan dikecualikan dari JKK: ' . $willExclude);
    if (!empty($missingExceptions)) {
        $this->warn('Kode pengecualian tidak ditemukan: ' . implode(', ', $missingExceptions));
    }

    if (!$apply) {
        $this->info('Dry-run selesai. Jalankan dengan --apply untuk update MySQL.');
        return 0;
    }

    $createdProfiles = 0;
    $updatedComponents = 0;

    DB::transaction(function () use ($employees, $hasFingerId, $exceptionMap, $effectiveStart, $jkk, &$createdProfiles, &$updatedComponents): void {
        foreach ($employees as $employee) {
            $employeeId = (int) $employee->id;
            $code = trim((string) ($employee->employee_code ?? ''));
            $finger = $hasFingerId ? trim((string) ($employee->finger_id ?? '')) : '';
            $isException = ($code !== '' && isset($exceptionMap[$code])) || ($finger !== '' && isset($exceptionMap[$finger]));

            $profile = DB::table('master_karyawan_bpjs_profiles')
                ->where('karyawan_id', $employeeId)
                ->where('is_active', true)
                ->whereDate('effective_start', '<=', $effectiveStart)
                ->where(function ($q) use ($effectiveStart) {
                    $q->whereNull('effective_end')
                        ->orWhereDate('effective_end', '>=', $effectiveStart);
                })
                ->orderByDesc('effective_start')
                ->orderByDesc('id')
                ->first(['id']);

            if (!$profile) {
                $profileId = DB::table('master_karyawan_bpjs_profiles')->insertGetId([
                    'karyawan_id' => $employeeId,
                    'profile_name' => 'AUTO JKK RULE',
                    'effective_start' => $effectiveStart,
                    'effective_end' => null,
                    'basis_general_nominal' => null,
                    'basis_jp_nominal' => null,
                    'basis_health_nominal' => null,
                    'notes' => 'Auto generated by bpjs:apply-jkk-rule',
                    'is_active' => true,
                    'created_at' => now(),
                    'updated_at' => now(),
                ]);
                $createdProfiles++;
            } else {
                $profileId = (int) $profile->id;
            }

            DB::table('master_karyawan_bpjs_profile_components')->updateOrInsert(
                [
                    'profile_id' => $profileId,
                    'bpjs_id' => (int) $jkk->id,
                ],
                [
                    'is_enrolled' => !$isException,
                    'notes' => $isException
                        ? 'Auto rule: pengecualian JKK sesuai daftar user.'
                        : 'Auto rule: enrolled JKK default.',
                    'updated_at' => now(),
                    'created_at' => now(),
                ]
            );
            $updatedComponents++;
        }
    });

    $this->info('Update MySQL selesai.');
    $this->line('Profile baru dibuat: ' . $createdProfiles);
    $this->line('Komponen JKK diupdate: ' . $updatedComponents);
    return 0;
})->purpose('Apply rule: semua karyawan enrolled JKK kecuali daftar kode tertentu');

Artisan::command('bpjs:rollback-jkk-rule
    {--apply : Benar-benar rollback MySQL}
    {--effective-start=2026-03-26 : Tanggal efektif profile BPJS auto JKK}
', function () {
    foreach (['master_bpjs', 'master_karyawan_bpjs_profiles', 'master_karyawan_bpjs_profile_components'] as $table) {
        if (!Schema::hasTable($table)) {
            $this->error("Tabel {$table} belum tersedia.");
            return 1;
        }
    }

    $apply = (bool) $this->option('apply');
    $effectiveStart = Carbon::parse((string) $this->option('effective-start'))->format('Y-m-d');

    $jkk = DB::table('master_bpjs')
        ->where(function ($q) {
            $q->whereRaw('UPPER(COALESCE(component_code, "")) = ?', ['JKK'])
                ->orWhereRaw('UPPER(COALESCE(code, "")) LIKE ?', ['%JKK%']);
        })
        ->orderBy('display_order')
        ->orderBy('id')
        ->first(['id', 'code', 'name', 'component_code']);

    if (!$jkk) {
        $this->error('Komponen BPJS JKK tidak ditemukan di master_bpjs.');
        return 1;
    }

    $autoComponentIds = DB::table('master_karyawan_bpjs_profile_components as c')
        ->join('master_karyawan_bpjs_profiles as p', 'p.id', '=', 'c.profile_id')
        ->where('c.bpjs_id', (int) $jkk->id)
        ->where(function ($q) {
            $q->where('c.notes', 'like', 'Auto rule:%JKK%')
                ->orWhere('p.profile_name', 'AUTO JKK RULE')
                ->orWhere('p.notes', 'like', '%bpjs:apply-jkk-rule%');
        })
        ->pluck('c.id');

    $autoProfileIds = DB::table('master_karyawan_bpjs_profiles')
        ->where('profile_name', 'AUTO JKK RULE')
        ->whereDate('effective_start', $effectiveStart)
        ->pluck('id');

    $this->line('Komponen rollback: ' . ($jkk->component_code ?: $jkk->code) . ' - ' . $jkk->name . ' (ID ' . $jkk->id . ')');
    $this->line('Tanggal efektif: ' . $effectiveStart);
    $this->line('Komponen JKK auto yang akan dihapus: ' . $autoComponentIds->count());
    $this->line('Profile AUTO JKK RULE yang akan dicek/dihapus bila kosong: ' . $autoProfileIds->count());

    if (!$apply) {
        $this->info('Dry-run rollback selesai. Jalankan dengan --apply untuk update MySQL.');
        return 0;
    }

    $deletedComponents = 0;
    $deletedProfiles = 0;

    DB::transaction(function () use ($autoComponentIds, $autoProfileIds, &$deletedComponents, &$deletedProfiles): void {
        if ($autoComponentIds->isNotEmpty()) {
            $deletedComponents = DB::table('master_karyawan_bpjs_profile_components')
                ->whereIn('id', $autoComponentIds->all())
                ->delete();
        }

        if ($autoProfileIds->isNotEmpty()) {
            $emptyAutoProfileIds = DB::table('master_karyawan_bpjs_profiles as p')
                ->leftJoin('master_karyawan_bpjs_profile_components as c', 'c.profile_id', '=', 'p.id')
                ->whereIn('p.id', $autoProfileIds->all())
                ->groupBy('p.id')
                ->havingRaw('COUNT(c.id) = 0')
                ->pluck('p.id');

            if ($emptyAutoProfileIds->isNotEmpty()) {
                $deletedProfiles = DB::table('master_karyawan_bpjs_profiles')
                    ->whereIn('id', $emptyAutoProfileIds->all())
                    ->delete();
            }
        }
    });

    $this->info('Rollback JKK selesai.');
    $this->line('Komponen JKK auto dihapus: ' . $deletedComponents);
    $this->line('Profile AUTO JKK RULE kosong dihapus: ' . $deletedProfiles);
    return 0;
})->purpose('Rollback auto rule JKK yang pernah dijalankan dari daftar pengecualian');

Artisan::command('bpjs:apply-jpk-rule
    {--apply : Benar-benar update MySQL}
    {--effective-start=2026-03-26 : Tanggal efektif profile BPJS}
    {--basis-health= : Isi basis health/JPK untuk karyawan yang ikut JPK}
    {--include-inactive : Ikut update karyawan nonaktif}
', function () {
    $exceptionCodes = [
        '41347', '41396', '41400', '30029', '30035', '30023', '30018', '30009', '10123', '42427',
        '42140', '40717', '41410', '42555', '42143', '41346', '42163', '41168', '1848', '40121',
        '42445', '40484', '30085', '42597', '41391', '41303', '42822', '42437', '42904', '41187',
        '30077', '41319', '30047', '41139', '42447', '41431', '41408', '42780', '42877', '42435',
        '42793', '30151', '42894', '42901', '42889', '40116', '42933', '42814', '42863', '42895',
        '42696', '40113', '42685', '41719', '42665', '41600', '41485', '30134', '41364', '41503',
        '41331', '42682', '41422', '41426', '41637', '41487', '41638', '41580', '42683', '42465',
        '42898', '42663', '42900', '42674', '42673', '42675', '42670', '42849', '42848', '42881',
        '42890', '42893', '42892', '42905', '41622', '42740', '41507', '42584', '42501', '41796',
        '42246', '41447', '30124', '42557', '30127', '41444', '20005', '41544', '42407', '41506',
        '42752', '42734', '30049', '42686', '42688', '42406', '30065', '41720', '42442', '41535',
        '42458', '41766', '1419', '1195',
    ];
    $exceptionMap = array_fill_keys($exceptionCodes, true);

    foreach (['master_bpjs', 'master_karyawans', 'master_karyawan_bpjs_profiles', 'master_karyawan_bpjs_profile_components'] as $table) {
        if (!Schema::hasTable($table)) {
            $this->error("Tabel {$table} belum tersedia.");
            return 1;
        }
    }

    $effectiveStart = Carbon::parse((string) $this->option('effective-start'))->format('Y-m-d');
    $apply = (bool) $this->option('apply');
    $basisHealthOption = trim((string) ($this->option('basis-health') ?? ''));
    $basisHealthNominal = $basisHealthOption !== ''
        ? round((float) str_replace([',', ' '], ['', ''], $basisHealthOption), 2)
        : null;

    $jpk = DB::table('master_bpjs')
        ->where(function ($q) {
            $q->whereRaw('UPPER(COALESCE(component_code, "")) = ?', ['JPK'])
                ->orWhereRaw('UPPER(COALESCE(code, "")) LIKE ?', ['%KES%'])
                ->orWhereRaw('UPPER(COALESCE(name, "")) LIKE ?', ['%KESEHATAN%']);
        })
        ->where('is_active', true)
        ->orderBy('display_order')
        ->orderBy('id')
        ->first(['id', 'code', 'name', 'component_code']);

    if (!$jpk) {
        $this->error('Komponen BPJS JPK/Kesehatan aktif tidak ditemukan di master_bpjs.');
        return 1;
    }

    $columns = ['id', 'employee_code', 'full_name'];
    $hasFingerId = Schema::hasColumn('master_karyawans', 'finger_id');
    $hasIsActive = Schema::hasColumn('master_karyawans', 'is_active');
    if ($hasFingerId) {
        $columns[] = 'finger_id';
    }
    if ($hasIsActive) {
        $columns[] = 'is_active';
    }

    $employeesQuery = DB::table('master_karyawans')->select($columns)->orderBy('employee_code');
    if ($hasIsActive && !(bool) $this->option('include-inactive')) {
        $employeesQuery->where('is_active', true);
    }
    $employees = $employeesQuery->get();

    $matchedExceptionCodes = [];
    $willEnroll = 0;
    $willExclude = 0;

    foreach ($employees as $employee) {
        $code = trim((string) ($employee->employee_code ?? ''));
        $finger = $hasFingerId ? trim((string) ($employee->finger_id ?? '')) : '';
        $isException = ($code !== '' && isset($exceptionMap[$code])) || ($finger !== '' && isset($exceptionMap[$finger]));
        if ($isException) {
            if ($code !== '' && isset($exceptionMap[$code])) {
                $matchedExceptionCodes[$code] = true;
            }
            if ($finger !== '' && isset($exceptionMap[$finger])) {
                $matchedExceptionCodes[$finger] = true;
            }
            $willExclude++;
        } else {
            $willEnroll++;
        }
    }

    $missingExceptions = array_values(array_diff($exceptionCodes, array_keys($matchedExceptionCodes)));

    $this->line('Komponen: ' . ($jpk->component_code ?: $jpk->code) . ' - ' . $jpk->name . ' (ID ' . $jpk->id . ')');
    $this->line('Tanggal efektif: ' . $effectiveStart);
    $this->line('Karyawan diproses: ' . $employees->count());
    $this->line('Akan enrolled JPK: ' . $willEnroll);
    $this->line('Akan dikecualikan dari JPK: ' . $willExclude);
    if ($basisHealthNominal !== null) {
        $this->line('Basis health/JPK untuk enrolled: ' . number_format($basisHealthNominal, 2, '.', ','));
    }
    if (!empty($missingExceptions)) {
        $this->warn('Kode pengecualian tidak ditemukan: ' . implode(', ', $missingExceptions));
    }

    if (!$apply) {
        $this->info('Dry-run selesai. Jalankan dengan --apply untuk update MySQL.');
        return 0;
    }

    $createdProfiles = 0;
    $updatedComponents = 0;

    DB::transaction(function () use ($employees, $hasFingerId, $exceptionMap, $effectiveStart, $jpk, $basisHealthNominal, &$createdProfiles, &$updatedComponents): void {
        foreach ($employees as $employee) {
            $employeeId = (int) $employee->id;
            $code = trim((string) ($employee->employee_code ?? ''));
            $finger = $hasFingerId ? trim((string) ($employee->finger_id ?? '')) : '';
            $isException = ($code !== '' && isset($exceptionMap[$code])) || ($finger !== '' && isset($exceptionMap[$finger]));

            $profile = DB::table('master_karyawan_bpjs_profiles')
                ->where('karyawan_id', $employeeId)
                ->where('is_active', true)
                ->whereDate('effective_start', '<=', $effectiveStart)
                ->where(function ($q) use ($effectiveStart) {
                    $q->whereNull('effective_end')
                        ->orWhereDate('effective_end', '>=', $effectiveStart);
                })
                ->orderByDesc('effective_start')
                ->orderByDesc('id')
                ->first(['id']);

            if (!$profile) {
                $profileId = DB::table('master_karyawan_bpjs_profiles')->insertGetId([
                    'karyawan_id' => $employeeId,
                    'profile_name' => 'AUTO JPK RULE',
                    'effective_start' => $effectiveStart,
                    'effective_end' => null,
                    'basis_general_nominal' => null,
                    'basis_jp_nominal' => null,
                    'basis_health_nominal' => (!$isException && $basisHealthNominal !== null) ? $basisHealthNominal : null,
                    'notes' => 'Auto generated by bpjs:apply-jpk-rule',
                    'is_active' => true,
                    'created_at' => now(),
                    'updated_at' => now(),
                ]);
                $createdProfiles++;
            } else {
                $profileId = (int) $profile->id;
                if (!$isException && $basisHealthNominal !== null) {
                    DB::table('master_karyawan_bpjs_profiles')
                        ->where('id', $profileId)
                        ->update([
                            'basis_health_nominal' => $basisHealthNominal,
                            'updated_at' => now(),
                        ]);
                }
            }

            DB::table('master_karyawan_bpjs_profile_components')->updateOrInsert(
                [
                    'profile_id' => $profileId,
                    'bpjs_id' => (int) $jpk->id,
                ],
                [
                    'is_enrolled' => !$isException,
                    'notes' => $isException
                        ? 'Auto rule: pengecualian JPK sesuai daftar user.'
                        : 'Auto rule: enrolled JPK default.',
                    'updated_at' => now(),
                    'created_at' => now(),
                ]
            );
            $updatedComponents++;
        }
    });

    $this->info('Update MySQL selesai.');
    $this->line('Profile baru dibuat: ' . $createdProfiles);
    $this->line('Komponen JPK diupdate: ' . $updatedComponents);
    return 0;
})->purpose('Apply rule: semua karyawan enrolled JPK kecuali daftar kode tertentu');

Artisan::command('docs:generate-system-guide', function () {
    $result = app(SystemDocumentationGenerator::class)->generate();

    $this->info('Dokumentasi sistem berhasil digenerate.');
    $this->line('Output folder: ' . ($result['out_dir'] ?? 'docs/generated'));
    $this->line('Total modul: ' . ($result['modules'] ?? 0));
    $this->line('Total tabel migration: ' . ($result['tables'] ?? 0));
    $this->line('Total migration file: ' . ($result['migrations'] ?? 0));
    $this->line('File utama:');

    foreach (($result['index']['files'] ?? []) as $label => $path) {
        $this->line('- ' . $label . ': ' . $path);
    }

    return 0;
})->purpose('Generate dokumentasi sistem dari migration, route, controller, model, view, dan modul');

Artisan::command('thirdparty:db-check
    {--connection=third_party : Nama connection database pihak ketiga}
    {--limit=20 : Jumlah tabel yang ditampilkan}
', function () {
    $connection = trim((string) $this->option('connection')) ?: 'third_party';
    $limit = max(1, min(200, (int) ($this->option('limit') ?: 20)));

    try {
        $pdo = DB::connection($connection)->getPdo();
        $database = DB::connection($connection)->getDatabaseName();
        $driver = DB::connection($connection)->getDriverName();
        $this->info("Connection OK: {$connection}");
        $this->line("Driver: {$driver}");
        $this->line("Database: {$database}");

        $tables = collect(Schema::connection($connection)->getTables())
            ->map(fn ($row) => (string) ($row['name'] ?? $row['table'] ?? $row['TABLE_NAME'] ?? ''))
            ->filter()
            ->values();

        $this->line('Tables: ' . $tables->count());
        foreach ($tables->take($limit) as $table) {
            $this->line('- ' . $table);
        }

        return $pdo ? 0 : 1;
    } catch (\Throwable $e) {
        $this->error('Connection failed: ' . $e->getMessage());
        return 1;
    }
})->purpose('Cek koneksi database pihak ketiga dan tampilkan daftar tabel');

Artisan::command('thirdparty:copy-table
    {table : Nama tabel yang dicopy}
    {--direction=push : push=AHSP ke third_party, pull=third_party ke AHSP}
    {--from= : Override source connection}
    {--to= : Override target connection}
    {--limit=0 : Batas record, 0 berarti semua}
    {--chunk=500 : Ukuran batch insert}
    {--truncate : Kosongkan target table sebelum insert}
    {--apply : Benar-benar eksekusi. Tanpa ini hanya dry-run}
', function () {
    $table = trim((string) $this->argument('table'));
    $direction = strtolower(trim((string) ($this->option('direction') ?: 'push')));
    $apply = (bool) $this->option('apply');
    $limit = max(0, (int) ($this->option('limit') ?: 0));
    $chunk = max(1, min(5000, (int) ($this->option('chunk') ?: 500)));

    $source = trim((string) ($this->option('from') ?: ''));
    $target = trim((string) ($this->option('to') ?: ''));
    if ($source === '' || $target === '') {
        if ($direction === 'pull') {
            $source = $source ?: 'third_party';
            $target = $target ?: config('database.default');
        } else {
            $source = $source ?: config('database.default');
            $target = $target ?: 'third_party';
        }
    }

    if (!Schema::connection($source)->hasTable($table)) {
        $this->error("Source table tidak ada: {$source}.{$table}");
        return 1;
    }
    if (!Schema::connection($target)->hasTable($table)) {
        $this->error("Target table tidak ada: {$target}.{$table}");
        return 1;
    }

    $sourceColumns = Schema::connection($source)->getColumnListing($table);
    $targetColumns = Schema::connection($target)->getColumnListing($table);
    $columns = array_values(array_intersect($sourceColumns, $targetColumns));

    if (empty($columns)) {
        $this->error('Tidak ada kolom yang sama antara source dan target.');
        return 1;
    }

    $totalQuery = DB::connection($source)->table($table);
    $total = (int) $totalQuery->count();
    $willProcess = $limit > 0 ? min($total, $limit) : $total;

    $this->line("Mode: {$direction}");
    $this->line("Source: {$source}.{$table}");
    $this->line("Target: {$target}.{$table}");
    $this->line('Common columns: ' . count($columns));
    $this->line('Source records: ' . $total);
    $this->line('Will process: ' . $willProcess);
    if ((bool) $this->option('truncate')) {
        $this->warn('Target table akan di-TRUNCATE sebelum insert.');
    }

    if (!$apply) {
        $this->info('Dry-run selesai. Tambahkan --apply untuk eksekusi copy.');
        return 0;
    }

    $inserted = 0;
    DB::connection($target)->transaction(function () use ($source, $target, $table, $columns, $limit, $chunk, &$inserted) {
        if ((bool) $this->option('truncate')) {
            DB::connection($target)->table($table)->truncate();
        }

        $query = DB::connection($source)
            ->table($table)
            ->select($columns)
            ->orderBy($columns[0]);

        if ($limit > 0) {
            $query->limit($limit);
        }

        $query->chunk($chunk, function ($rows) use ($target, $table, $columns, &$inserted) {
            $payload = [];
            foreach ($rows as $row) {
                $item = [];
                foreach ($columns as $column) {
                    $item[$column] = $row->{$column} ?? null;
                }
                $payload[] = $item;
            }

            if (!empty($payload)) {
                DB::connection($target)->table($table)->insertOrIgnore($payload);
                $inserted += count($payload);
            }
        });
    });

    $this->info('Copy selesai.');
    $this->line('Rows submitted: ' . $inserted);
    return 0;
})->purpose('Copy data dummy antar database AHSP dan database pihak ketiga, default dry-run');

Artisan::command('thirdparty:sync-job-number
    {--connection=third_party : Nama connection database pihak ketiga}
    {--source-table=master_project : Nama tabel project di database pihak ketiga}
    {--job-column= : Kolom nomor job sumber, kosongkan untuk auto-detect}
    {--name-column= : Kolom nama project sumber, kosongkan untuk auto-detect}
    {--wo-column= : Kolom nomor WO sumber, kosongkan untuk auto-detect}
    {--limit=0 : Batas record, 0 berarti semua}
    {--chunk=500 : Ukuran batch}
    {--update-existing : Update nama/no WO project yang sudah ada}
    {--no-create : Jangan buat project baru jika nomor job belum ada}
    {--apply : Benar-benar eksekusi. Tanpa ini hanya dry-run}
', function () {
    $connection = trim((string) ($this->option('connection') ?: 'third_party'));
    $sourceTable = trim((string) ($this->option('source-table') ?: 'master_project'));
    $apply = (bool) $this->option('apply');
    $updateExisting = (bool) $this->option('update-existing');
    $createMissing = !(bool) $this->option('no-create');
    $limit = max(0, (int) ($this->option('limit') ?: 0));
    $chunk = max(1, min(5000, (int) ($this->option('chunk') ?: 500)));

    if (!Schema::connection($connection)->hasTable($sourceTable)) {
        $this->error("Tabel sumber tidak ditemukan: {$connection}.{$sourceTable}");
        return 1;
    }
    if (!Schema::hasTable('master_projects')) {
        $this->error('Tabel target master_projects belum tersedia.');
        return 1;
    }

    $sourceColumns = Schema::connection($connection)->getColumnListing($sourceTable);
    $pickColumn = function (?string $explicit, array $candidates) use ($sourceColumns): ?string {
        $explicit = trim((string) $explicit);
        if ($explicit !== '') {
            return in_array($explicit, $sourceColumns, true) ? $explicit : null;
        }

        $lowerMap = [];
        foreach ($sourceColumns as $column) {
            $lowerMap[strtolower((string) $column)] = (string) $column;
        }

        foreach ($candidates as $candidate) {
            $key = strtolower((string) $candidate);
            if (isset($lowerMap[$key])) {
                return $lowerMap[$key];
            }
        }

        return null;
    };

    $jobColumn = $pickColumn($this->option('job-column'), [
        'no_job', 'nomor_job', 'nojob', 'job_no', 'job_number', 'no_project', 'nomor_project',
        'project_no', 'project_number', 'code_project', 'kode_project', 'kode_job', 'project_code', 'code',
    ]);
    $nameColumn = $pickColumn($this->option('name-column'), [
        'project_name', 'nama_project', 'nama_proyek', 'name', 'nama', 'judul', 'description', 'keterangan',
    ]);
    $woColumn = $pickColumn($this->option('wo-column'), [
        'no_wo', 'nomor_wo', 'wo_no', 'wo_number', 'work_order', 'no_work_order',
    ]);
    $startColumn = $pickColumn(null, ['start_work', 'tgl_mulai', 'tanggal_mulai', 'start_date']);
    $endColumn = $pickColumn(null, ['end_work', 'tgl_selesai', 'tanggal_selesai', 'end_date']);
    $amountColumn = $pickColumn(null, ['wo_amount', 'amount_wo', 'nilai_wo', 'nominal_wo', 'contract_amount']);

    if (!$jobColumn) {
        $this->error('Kolom nomor job tidak ditemukan. Isi manual dengan --job-column=nama_kolom.');
        $this->line('Kolom tersedia: ' . implode(', ', $sourceColumns));
        return 1;
    }

    $this->line("Source: {$connection}.{$sourceTable}");
    $this->line("Target: mysql.master_projects");
    $this->line("Job column: {$jobColumn}");
    $this->line('Name column: ' . ($nameColumn ?: '-'));
    $this->line('WO column: ' . ($woColumn ?: '-'));
    $this->line('Mode: ' . ($apply ? 'APPLY' : 'DRY-RUN'));

    $baseQuery = DB::connection($connection)->table($sourceTable)->whereNotNull($jobColumn);
    $sourceTotal = (int) (clone $baseQuery)->count();
    $willProcess = $limit > 0 ? min($sourceTotal, $limit) : $sourceTotal;
    $this->line('Source rows with job number: ' . $sourceTotal);
    $this->line('Will process: ' . $willProcess);

    $stats = [
        'processed' => 0,
        'created' => 0,
        'updated' => 0,
        'skipped_empty' => 0,
        'skipped_existing' => 0,
        'skipped_no_create' => 0,
    ];

    $query = DB::connection($connection)->table($sourceTable)->whereNotNull($jobColumn)->orderBy($jobColumn);
    if ($limit > 0) {
        $query->limit($limit);
    }

    $query->chunk($chunk, function ($rows) use (
        $jobColumn,
        $nameColumn,
        $woColumn,
        $startColumn,
        $endColumn,
        $amountColumn,
        $apply,
        $updateExisting,
        $createMissing,
        &$stats
    ) {
        foreach ($rows as $source) {
            $stats['processed']++;
            $jobNo = trim((string) ($source->{$jobColumn} ?? ''));
            if ($jobNo === '') {
                $stats['skipped_empty']++;
                continue;
            }

            $projectName = $nameColumn ? trim((string) ($source->{$nameColumn} ?? '')) : '';
            if ($projectName === '') {
                $projectName = $jobNo;
            }
            $woNo = $woColumn ? trim((string) ($source->{$woColumn} ?? '')) : '';

            $existing = DB::table('master_projects')
                ->where(function ($q) use ($jobNo) {
                    $q->where('code_project', $jobNo)
                        ->orWhere('code', $jobNo)
                        ->orWhere('no_wo', $jobNo);
                })
                ->first();

            if ($existing) {
                if (!$updateExisting) {
                    $stats['skipped_existing']++;
                    continue;
                }

                $payload = [
                    'code_project' => $jobNo,
                    'code' => $jobNo,
                    'name' => $projectName,
                    'updated_at' => now(),
                ];
                if (Schema::hasColumn('master_projects', 'no_wo')) {
                    $payload['no_wo'] = $woNo !== '' ? $woNo : ($existing->no_wo ?? null);
                }
                if ($startColumn && Schema::hasColumn('master_projects', 'start_work')) {
                    $payload['start_work'] = $source->{$startColumn} ?? null;
                }
                if ($endColumn && Schema::hasColumn('master_projects', 'end_work')) {
                    $payload['end_work'] = $source->{$endColumn} ?? null;
                }
                if ($amountColumn && Schema::hasColumn('master_projects', 'wo_amount')) {
                    $payload['wo_amount'] = is_numeric($source->{$amountColumn} ?? null) ? (float) $source->{$amountColumn} : null;
                }

                if ($apply) {
                    DB::table('master_projects')->where('id', (int) $existing->id)->update($payload);
                }
                $stats['updated']++;
                continue;
            }

            if (!$createMissing) {
                $stats['skipped_no_create']++;
                continue;
            }

            $payload = [
                'code' => $jobNo,
                'code_project' => $jobNo,
                'name' => $projectName,
                'description' => 'Synced from third_party.master_project',
                'is_active' => true,
                'created_at' => now(),
                'updated_at' => now(),
            ];
            if (Schema::hasColumn('master_projects', 'no_wo')) {
                $payload['no_wo'] = $woNo !== '' ? $woNo : null;
            }
            if (Schema::hasColumn('master_projects', 'project_status')) {
                $payload['project_status'] = 'draft_penawaran';
            }
            if ($startColumn && Schema::hasColumn('master_projects', 'start_work')) {
                $payload['start_work'] = $source->{$startColumn} ?? null;
            }
            if ($endColumn && Schema::hasColumn('master_projects', 'end_work')) {
                $payload['end_work'] = $source->{$endColumn} ?? null;
            }
            if ($amountColumn && Schema::hasColumn('master_projects', 'wo_amount')) {
                $payload['wo_amount'] = is_numeric($source->{$amountColumn} ?? null) ? (float) $source->{$amountColumn} : null;
            }

            if ($apply) {
                DB::table('master_projects')->insert($payload);
            }
            $stats['created']++;
        }
    });

    $this->line('Processed: ' . $stats['processed']);
    $this->line(($apply ? 'Created: ' : 'Would create: ') . $stats['created']);
    $this->line(($apply ? 'Updated: ' : 'Would update: ') . $stats['updated']);
    $this->line('Skipped existing: ' . $stats['skipped_existing']);
    $this->line('Skipped no-create: ' . $stats['skipped_no_create']);
    $this->line('Skipped empty: ' . $stats['skipped_empty']);

    if (!$apply) {
        $this->info('Dry-run selesai. Tambahkan --apply untuk eksekusi sync.');
    } else {
        $this->info('Sync nomor job selesai.');
    }

    return 0;
})->purpose('Sinkronisasi nomor job dari database pihak ketiga table master_project ke master_projects AHSP');

Artisan::command('thirdparty:sync-job-number-ui {job_id} {payload}', function () {
    $jobId = (string) $this->argument('job_id');
    $payload = (string) $this->argument('payload');
    $cacheKey = 'master_project:sync_job_number:' . preg_replace('/[^A-Za-z0-9_-]/', '', $jobId);
    $putStatus = function (array $status) use ($cacheKey, $jobId): void {
        Cache::put($cacheKey, array_merge((array) Cache::get($cacheKey, []), [
            'job_id' => $jobId,
            'updated_at' => now()->toDateTimeString(),
        ], $status), now()->addHours(6));
    };

    $decoded = json_decode(base64_decode($payload, true) ?: '{}', true);
    $params = is_array($decoded) ? $decoded : [];
    $params['cancel_check'] = function () use ($cacheKey): bool {
        $current = Cache::get($cacheKey, []);
        return (bool) ($current['cancel_requested'] ?? false);
    };
    $params['progress_callback'] = function (array $progress) use ($putStatus): void {
        $current = Cache::get($cacheKey, []);
        $cancelRequested = (bool) ($current['cancel_requested'] ?? false);
        $putStatus([
            'status' => $cancelRequested ? 'cancelling' : 'processing',
            'message' => $cancelRequested
                ? 'Permintaan stop diterima. Menyelesaikan checkpoint terakhir...'
                : (string) ($progress['message'] ?? 'Memproses sync nomor job...'),
            'processed' => (int) ($progress['processed'] ?? 0),
            'total' => (int) ($progress['total'] ?? 0),
            'percent' => (float) ($progress['percent'] ?? 0),
            'stats' => $progress['stats'] ?? [],
            'errors' => $progress['errors'] ?? [],
        ]);
    };

    $putStatus([
        'status' => 'processing',
        'message' => 'Worker sync nomor job mulai berjalan.',
        'processed' => 0,
        'total' => 0,
        'percent' => 0,
        'stats' => [],
        'errors' => [],
        'started_at' => now()->toDateTimeString(),
    ]);

    try {
        if (($params['cancel_check'])()) {
            $putStatus([
                'status' => 'cancelled',
                'message' => 'Job dibatalkan sebelum worker mulai memproses data.',
                'processed' => 0,
                'total' => 0,
                'percent' => 0,
                'stats' => [],
                'errors' => [],
                'output' => 'Job dibatalkan sebelum worker mulai memproses data.',
                'finished_at' => now()->toDateTimeString(),
            ]);

            $this->warn('Job dibatalkan sebelum worker mulai memproses data.');
            return 0;
        }

        $result = app(ThirdPartyJobNumberSyncService::class)->sync($params);
        $ok = (bool) ($result['ok'] ?? false);
        $cancelled = (bool) ($result['cancelled'] ?? false);
        $putStatus([
            'status' => $cancelled ? 'cancelled' : ($ok ? 'done' : 'failed'),
            'message' => (string) ($result['message'] ?? ($cancelled ? 'Sync dibatalkan.' : ($ok ? 'Sync selesai.' : 'Sync gagal.'))),
            'processed' => (int) ($result['stats']['processed'] ?? 0),
            'total' => (int) ($result['meta']['will_process'] ?? ($result['stats']['processed'] ?? 0)),
            'percent' => $cancelled ? (float) ($result['percent'] ?? 0) : ($ok ? 100 : (float) 0),
            'stats' => $result['stats'] ?? [],
            'errors' => $result['errors'] ?? [],
            'output' => (string) ($result['output'] ?? ''),
            'finished_at' => now()->toDateTimeString(),
        ]);

        $this->line((string) ($result['output'] ?? ''));
        return ($ok || $cancelled) ? 0 : 1;
    } catch (Throwable $e) {
        $errorId = 'MPJ-WORKER-' . now()->format('YmdHis');
        $output = implode("\n", [
            "Error ID {$errorId}: " . $e->getMessage(),
            $e->getFile() . ':' . $e->getLine(),
        ]);
        \Illuminate\Support\Facades\Log::error('MasterProject sync job worker failed', [
            'error_id' => $errorId,
            'message' => $e->getMessage(),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'trace_head' => array_slice($e->getTrace(), 0, 8),
        ]);

        $putStatus([
            'status' => 'failed',
            'message' => 'Sync nomor job gagal. Error ID ' . $errorId,
            'processed' => 0,
            'total' => 0,
            'percent' => 0,
            'stats' => [],
            'errors' => [$output],
            'output' => $output,
            'finished_at' => now()->toDateTimeString(),
        ]);
        $this->error($output);
        return 1;
    }
})->purpose('Worker background untuk sync nomor job dari UI Master Project');

Artisan::command('thirdparty:sync-spl-ui {job_id} {payload}', function () {
    $jobId = (string) $this->argument('job_id');
    $payload = (string) $this->argument('payload');
    $cacheKey = 'surat_perintah_lembur:third_party_sync:' . preg_replace('/[^A-Za-z0-9_-]/', '', $jobId);
    $statusPath = storage_path('app/sync-status/spl-third-party-' . strtolower(preg_replace('/[^A-Za-z0-9_-]/', '', $jobId)) . '.json');
    $statusDir = dirname($statusPath);
    if (!is_dir($statusDir)) {
        @mkdir($statusDir, 0775, true);
    }
    $putStatus = function (array $status) use ($cacheKey, $jobId, $statusPath): void {
        $payload = array_merge([
            'job_id' => $jobId,
            'updated_at' => now()->toDateTimeString(),
        ], $status);
        Cache::put($cacheKey, $payload, now()->addHours(6));
        @file_put_contents($statusPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
    };

    $decoded = json_decode(base64_decode($payload, true) ?: '{}', true);
    $params = is_array($decoded) ? $decoded : [];
    $params['progress_callback'] = function (array $progress) use ($putStatus): void {
        $putStatus([
            'status' => 'processing',
            'message' => (string) ($progress['message'] ?? 'Memproses sync SPL...'),
            'processed' => (int) ($progress['processed'] ?? 0),
            'total' => (int) ($progress['total'] ?? 0),
            'percent' => (float) ($progress['percent'] ?? 0),
            'stats' => $progress['stats'] ?? [],
            'errors' => $progress['errors'] ?? [],
        ]);
    };

    $putStatus([
        'status' => 'processing',
        'message' => 'Worker sync SPL mulai berjalan.',
        'processed' => 0,
        'total' => 0,
        'percent' => 0,
        'stats' => [],
        'errors' => [],
        'started_at' => now()->toDateTimeString(),
    ]);

    try {
        $result = app(ThirdPartySplSyncService::class)->sync($params);
        $ok = (bool) ($result['ok'] ?? false);
        $putStatus([
            'status' => $ok ? 'done' : 'failed',
            'message' => (string) ($result['message'] ?? ($ok ? 'Sync selesai.' : 'Sync gagal.')),
            'processed' => (int) ($result['stats']['processed'] ?? 0),
            'total' => (int) ($result['meta']['source_rows'] ?? ($result['stats']['processed'] ?? 0)),
            'percent' => $ok ? 100 : 0,
            'stats' => $result['stats'] ?? [],
            'errors' => $result['errors'] ?? [],
            'output' => (string) ($result['output'] ?? ''),
            'finished_at' => now()->toDateTimeString(),
        ]);

        $this->line((string) ($result['output'] ?? ''));
        return $ok ? 0 : 1;
    } catch (Throwable $e) {
        $putStatus([
            'status' => 'failed',
            'message' => 'Sync SPL gagal: ' . $e->getMessage(),
            'processed' => 0,
            'total' => 0,
            'percent' => 0,
            'stats' => [],
            'errors' => [$e->getMessage()],
            'output' => $e->getMessage(),
            'finished_at' => now()->toDateTimeString(),
        ]);
        $this->error($e->getMessage());
        return 1;
    }
})->purpose('Worker background untuk sync SPL dari database pihak ketiga ke surat_perintah_lemburs');

Artisan::command('thirdparty:sync-leave-ui {job_id} {payload}', function () {
    $jobId = (string) $this->argument('job_id');
    $payload = (string) $this->argument('payload');
    $cacheKey = 'leave_request:third_party_sync:' . preg_replace('/[^A-Za-z0-9_-]/', '', $jobId);
    $statusPath = storage_path('app/sync-status/leave-third-party-' . strtolower(preg_replace('/[^A-Za-z0-9_-]/', '', $jobId)) . '.json');
    $statusDir = dirname($statusPath);
    if (!is_dir($statusDir)) {
        @mkdir($statusDir, 0775, true);
    }
    $putStatus = function (array $status) use ($cacheKey, $jobId, $statusPath): void {
        $payload = array_merge([
            'job_id' => $jobId,
            'updated_at' => now()->toDateTimeString(),
        ], $status);
        Cache::put($cacheKey, $payload, now()->addHours(6));
        @file_put_contents($statusPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
    };

    $decoded = json_decode(base64_decode($payload, true) ?: '{}', true);
    $params = is_array($decoded) ? $decoded : [];
    $params['progress_callback'] = function (array $progress) use ($putStatus): void {
        $putStatus([
            'status' => 'processing',
            'message' => (string) ($progress['message'] ?? 'Memproses sync leave...'),
            'processed' => (int) ($progress['processed'] ?? 0),
            'total' => (int) ($progress['total'] ?? 0),
            'percent' => (float) ($progress['percent'] ?? 0),
            'stats' => $progress['stats'] ?? [],
            'errors' => $progress['errors'] ?? [],
        ]);
    };

    $putStatus([
        'status' => 'processing',
        'message' => 'Worker sync leave mulai berjalan.',
        'processed' => 0,
        'total' => 0,
        'percent' => 0,
        'stats' => [],
        'errors' => [],
        'started_at' => now()->toDateTimeString(),
    ]);

    try {
        $result = app(ThirdPartyLeaveSyncService::class)->sync($params);
        $ok = (bool) ($result['ok'] ?? false);
        $putStatus([
            'status' => $ok ? 'done' : 'failed',
            'message' => (string) ($result['message'] ?? ($ok ? 'Sync selesai.' : 'Sync gagal.')),
            'processed' => (int) ($result['stats']['processed'] ?? 0),
            'total' => (int) ($result['meta']['source_rows'] ?? ($result['stats']['processed'] ?? 0)),
            'percent' => $ok ? 100 : 0,
            'stats' => $result['stats'] ?? [],
            'errors' => $result['errors'] ?? [],
            'output' => (string) ($result['output'] ?? ''),
            'finished_at' => now()->toDateTimeString(),
        ]);

        $this->line((string) ($result['output'] ?? ''));
        return $ok ? 0 : 1;
    } catch (Throwable $e) {
        $putStatus([
            'status' => 'failed',
            'message' => 'Sync leave gagal: ' . $e->getMessage(),
            'processed' => 0,
            'total' => 0,
            'percent' => 0,
            'stats' => [],
            'errors' => [$e->getMessage()],
            'output' => $e->getMessage(),
            'finished_at' => now()->toDateTimeString(),
        ]);
        $this->error($e->getMessage());
        return 1;
    }
})->purpose('Worker background untuk sync leave dari database pihak ketiga ke leave_requests');

Artisan::command('attendance-review:prune-histories
    {--compact-days=0 : Umur data (hari) untuk mulai mengosongkan before/after json, 0 = pakai setting UI}
    {--delete-days=0 : Umur data (hari) untuk hapus total riwayat, 0 = pakai setting UI}
    {--chunk=1000 : Ukuran batch proses}
    {--dry-run : Hanya simulasi, tanpa update/hapus}
    {--force : Jalankan meski toggle auto prune dimatikan}
', function () {
    $service = app(AttendanceHistoryRetentionService::class);
    $saved = $service->getSettings();
    $compactDays = (int) $this->option('compact-days');
    $deleteDays = (int) $this->option('delete-days');
    $chunk = max(100, min(5000, (int) $this->option('chunk')));
    $dryRun = (bool) $this->option('dry-run');
    $force = (bool) $this->option('force');

    $settings = [
        'enabled' => $saved['enabled'],
        'compact_days' => $compactDays > 0 ? $compactDays : $saved['compact_days'],
        'delete_days' => $deleteDays > 0 ? $deleteDays : $saved['delete_days'],
        'chunk' => $chunk,
    ];

    $result = $service->run($settings, $dryRun, $force);
    $output = trim((string) ($result['output'] ?? ''));

    if ($output !== '') {
        $this->line($output);
    }

    if (($result['status'] ?? '') === 'disabled') {
        $this->warn('Auto prune sedang nonaktif. Gunakan --force jika ingin tetap jalan manual dari CLI.');
        return 0;
    }

    return ($result['ok'] ?? false) ? 0 : 1;
})->purpose('Padatkan dan hapus history attendance review yang sudah lama supaya database tetap ringan');
