<?php

namespace Modules\ClinicCore\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Schema;
use Modules\ClinicCore\Entities\ClcAppointment;
use Modules\ClinicCore\Entities\ClcAppointmentNote;
use Modules\ClinicCore\Entities\ClcExamNotDoneReason;
use Modules\ClinicCore\Entities\ClcDoctor;
use Modules\ClinicCore\Entities\ClcDepartment;
use Modules\ClinicCore\Entities\ClcClinicLocation;
use Modules\ClinicCore\Entities\ClcAppointmentType;
use Modules\ClinicCore\Entities\ClcAppointmentReason;
use Modules\ClinicCore\Entities\ClcDoctorSchedule;
use Modules\ClinicCore\Entities\ClcModuleSetting;
use Modules\ClinicCore\Entities\ClcPatient;
use Modules\ClinicCore\Entities\ClcVital;
use Modules\ClinicCore\Entities\ClcAppointmentDocument;
use Modules\ClinicCore\Entities\ClcAppointmentStatusLog;
use Modules\ClinicCore\Entities\ClcAppointmentEditRequest;
use Carbon\Carbon;
use Illuminate\Support\Facades\Storage;
use Yajra\DataTables\Facades\DataTables;

class ClcAppointmentController extends Controller
{
    use \Modules\ClinicCore\Http\Controllers\Traits\LogsClcActivity;

    /** Filled by buildPosLineItems(); surfaced to the POS bridge banner. */
    private array $lastSkippedExams = [];

    public function index()
    {
        if (!auth()->user()->can('clinic.view_appointments') && !auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId  = request()->session()->get('user.business_id');
        $doctors     = ClcDoctor::where('business_id', $businessId)->active()->orderBy('name')->get();
        $departments = ClcDepartment::where('business_id', $businessId)->where('status', 'active')->orderBy('name')->get();
        $aptTypes    = ClcAppointmentType::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();

        // Status counts for badges
        $statusCounts = ClcAppointment::where('business_id', $businessId)
            ->selectRaw('status, COUNT(*) as cnt')
            ->groupBy('status')
            ->pluck('cnt', 'status');

        return view('clinicCore::appointments.index', compact('doctors', 'departments', 'aptTypes', 'statusCounts'));
    }

    public function calendar()
    {
        if (!auth()->user()->can('clinic.view_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId  = request()->session()->get('user.business_id');
        $doctors     = ClcDoctor::where('business_id', $businessId)->active()->orderBy('name')->get();
        $departments = \Modules\ClinicCore\Entities\ClcDepartment::where('business_id', $businessId)
            ->where('status', 'active')->orderBy('name')->get();

        $todayAppointments = ClcAppointment::with(['pet', 'owner', 'doctor'])
            ->where('business_id', $businessId)
            ->whereDate('appointment_date', today())
            ->orderBy('appointment_time')
            ->get();

        $aptTypes     = ClcAppointmentType::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();
        $aptReasons   = ClcAppointmentReason::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();
        $allowOverlap = ClcModuleSetting::get($businessId, 'allow_overlap', '1') !== '0';

        return view('clinicCore::appointments.calendar', compact('doctors', 'departments', 'todayAppointments', 'aptTypes', 'aptReasons', 'allowOverlap'));
    }

    public function events(Request $request)
    {
        $businessId = request()->session()->get('user.business_id');

        $query = ClcAppointment::with(['pet', 'owner', 'doctor', 'department'])
            ->where('business_id', $businessId);

        if ($request->start) {
            $query->where('appointment_date', '>=', substr($request->start, 0, 10));
        }
        if ($request->end) {
            $query->where('appointment_date', '<', substr($request->end, 0, 10));
        }
        if ($request->doctor_id) {
            $query->where('doctor_id', $request->doctor_id);
        }
        // Per-role doctor scope (e.g. doctors who should only see their own
        // calendar). Wins over any client-supplied doctor_id filter. Pet
        // profile views deliberately don't use this and stay unfiltered.
        $scopeDoctorId = \Modules\ClinicCore\Entities\Services\ClcRoleScopeService::appointmentDoctorScope(auth()->user(), $businessId);
        if ($scopeDoctorId !== null) {
            $query->where('doctor_id', $scopeDoctorId);
        }
        // Allow filtering by either DB type_ids (comma-list) or the enum.
        // 'none' is a sentinel meaning "no types selected" → return zero results.
        if ($request->filled('type_ids')) {
            if ($request->type_ids === 'none') {
                $query->whereRaw('1 = 0');
            } else {
                $ids = is_array($request->type_ids) ? $request->type_ids : explode(',', $request->type_ids);
                $ids = array_filter(array_map('intval', $ids));
                if (!empty($ids)) {
                    // type_ids JSON values are stored as STRINGS in some rows and
                    // INTEGERS in others (legacy data). MySQL JSON_CONTAINS is
                    // type-strict, so we have to query for both forms.
                    $query->where(function ($q) use ($ids) {
                        foreach ($ids as $tid) {
                            $q->orWhereJsonContains('type_ids', $tid)
                              ->orWhereJsonContains('type_ids', (string) $tid);
                        }
                    });
                }
            }
        } elseif ($request->filled('type')) {
            if ($request->type === 'none') {
                $query->whereRaw('1 = 0');
            } else {
                // Accept CSV (e.g. "checkup,surgery") or single value
                $vals = array_filter(array_map('trim', explode(',', $request->type)));
                if (count($vals) === 1) {
                    $query->where('type', $vals[0]);
                } elseif (count($vals) > 1) {
                    $query->whereIn('type', $vals);
                }
            }
        }

        // Build DB-driven type lookup once (id => {color, icon, name})
        $typeLookup = ClcAppointmentType::forBusiness($businessId)
            ->get(['id', 'name', 'color', 'icon'])
            ->keyBy('id');

        // Legacy enum fallback (used when an appointment has no type_ids)
        $enumColors = ['checkup'=>'#2980b9','vaccination'=>'#27ae60','surgery'=>'#c0392b','grooming'=>'#8e44ad','emergency'=>'#e67e22','other'=>'#7f8c8d'];
        $enumIcons  = ['checkup'=>'fa-stethoscope','vaccination'=>'fa-syringe','surgery'=>'fa-procedures','grooming'=>'fa-cut','emergency'=>'fa-ambulance','other'=>'fa-notes-medical'];
        $statusColors = ['pending'=>'#f39c12','waiting'=>'#0d9488','confirmed'=>'#3498db','in_progress'=>'#9b59b6','completed'=>'#27ae60','cancelled'=>'#e74c3c','no_show'=>'#95a5a6'];

        return response()->json($query->get()->map(function ($a) use ($typeLookup, $enumColors, $enumIcons, $statusColors) {
            $ownerName = trim(optional($a->owner)->name ?? '') ?: (optional($a->owner)->supplier_business_name ?? '');
            $endTime   = \Carbon\Carbon::parse($a->appointment_date->format('Y-m-d') . ' ' . $a->appointment_time)
                            ->addMinutes($a->duration_minutes ?? 30)
                            ->format('Y-m-d\TH:i:s');

            // Resolve type info from DB catalogue first, fall back to enum
            $typeNames  = [];
            $typeColors = [];
            $typeIcons  = [];
            $primaryTypeColor = null;
            $primaryTypeIcon  = null;

            if (is_array($a->type_ids) && !empty($a->type_ids)) {
                foreach ($a->type_ids as $tid) {
                    $row = $typeLookup->get((int) $tid);
                    if (!$row) continue;
                    $typeNames[]  = $row->name;
                    $typeColors[] = $row->color ?: '#7f8c8d';
                    $typeIcons[]  = $row->icon  ?: 'fa-calendar-check';
                }
            }
            if (empty($typeNames)) {
                $typeNames[]  = ucfirst($a->type ?: 'other');
                $typeColors[] = $enumColors[$a->type] ?? '#7f8c8d';
                $typeIcons[]  = $enumIcons[$a->type]  ?? 'fa-calendar-check';
            }
            $primaryTypeColor = $typeColors[0];
            $primaryTypeIcon  = $typeIcons[0];

            $doctorColor = optional($a->doctor)->color ?: null;
            // Background priority: doctor color > primary type color
            $bgColor     = $doctorColor ?: $primaryTypeColor;

            return [
                'id'              => $a->id,
                'title'           => optional($a->pet)->patient_name . ' — ' . $ownerName,
                'start'           => $a->appointment_date->format('Y-m-d') . 'T' . $a->appointment_time,
                'end'             => $endTime,
                'backgroundColor' => $bgColor,
                'borderColor'     => $bgColor,
                'extendedProps'   => [
                    'patient_id'       => $a->patient_id,
                    'pet_name'     => optional($a->pet)->patient_name,
                    'pet_species'  => optional($a->pet)->species,
                    'pet_breed'    => optional($a->pet)->breed,
                    'pet_photo'    => optional($a->pet)->photo_url,
                    'owner_name'   => $ownerName,
                    'owner_mobile' => optional($a->owner)->mobile,
                    'contact_id'   => $a->contact_id,
                    'doctor_name'  => optional($a->doctor)->name,
                    'doctor_color' => $doctorColor,
                    'department'   => optional($a->department)->name,
                    'type'         => $a->type,
                    'type_ids'     => $a->type_ids ?? [],
                    'type_names'   => $typeNames,
                    'type_colors'  => $typeColors,
                    'type_icons'   => $typeIcons,
                    'type_color'   => $primaryTypeColor,
                    'type_icon'    => $primaryTypeIcon,
                    'status'       => $a->status,
                    'status_color' => $statusColors[$a->status] ?? '#95a5a6',
                    'reason'       => $a->reason,
                    'duration'     => $a->duration_minutes ?? 30,
                    'url'          => route('clinic.appointments.show', $a->id),
                    'edit_url'     => route('clinic.appointments.edit', $a->id),
                    'status_url'   => route('clinic.appointments.status', $a->id),
                ],
            ];
        }));
    }

    public function list(Request $request)
    {
        if (!auth()->user()->can('clinic.view_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId = request()->session()->get('user.business_id');

        $query = ClcAppointment::with(['pet', 'owner', 'doctor', 'department'])
            ->where('business_id', $businessId);

        if ($request->status && $request->status !== 'all') {
            $query->where('status', $request->status);
        }
        if ($request->filled('date_from')) {
            $query->whereDate('appointment_date', '>=', $request->date_from);
        }
        if ($request->filled('date_to')) {
            $query->whereDate('appointment_date', '<=', $request->date_to);
        }
        if ($request->filter_doctor_id) {
            $query->where('doctor_id', $request->filter_doctor_id);
        }
        // Per-role doctor scope — see events() for the same logic.
        $scopeDoctorId = \Modules\ClinicCore\Entities\Services\ClcRoleScopeService::appointmentDoctorScope(auth()->user(), $businessId);
        if ($scopeDoctorId !== null) {
            $query->where('doctor_id', $scopeDoctorId);
        }
        if ($request->filter_department_id) {
            $query->where('department_id', $request->filter_department_id);
        }
        if ($request->filter_type) {
            $query->where('type', $request->filter_type);
        }
        if ($request->filter_species) {
            $query->whereHas('pet', fn($q) => $q->where('species', $request->filter_species));
        }
        if ($request->filter_search) {
            $term = $request->filter_search;
            $query->where(function ($q) use ($term) {
                $q->where('ref_no', 'like', '%' . $term . '%')
                  ->orWhereHas('pet',   fn($sq) => $sq->where('patient_name', 'like', '%' . $term . '%'))
                  ->orWhereHas('owner', fn($sq) => $sq->where('name', 'like', '%' . $term . '%')
                                                        ->orWhere('mobile', 'like', '%' . $term . '%'));
            });
        }

        $speciesIcons = ['dog' => '🐕', 'cat' => '🐈', 'rabbit' => '🐇', 'bird' => '🦜'];
        $dateFormat   = session('business.date_format', 'd M Y');
        $timeFormat   = session('business.time_format', 24) == 12 ? 'h:i A' : 'H:i';

        return DataTables::of($query)
            ->addColumn('datetime_col', function ($row) use ($dateFormat, $timeFormat) {
                $date = optional($row->appointment_date)->format($dateFormat) ?? '—';
                $time = $row->appointment_time
                    ? \Carbon\Carbon::parse($row->appointment_time)->format($timeFormat) : '';
                return $date . ($time ? '<br><small class="text-muted" style="font-size:10px;">' . $time . '</small>' : '');
            })
            ->addColumn('pet_name', function ($row) use ($speciesIcons) {
                $patient = $row->pet;
                if (!$patient) return '—';
                $icon = $speciesIcons[strtolower($patient->species ?? '')] ?? '🐾';
                $url  = route('clinic.patients.show', $patient->id);
                $name = '<a href="' . $url . '" style="font-weight:600;">' . e($patient->patient_name) . '</a>';
                $sub  = (config('cliniccore.show_animal_fields', false) && $patient->species) ? '<br><small class="text-muted">' . $icon . ' ' . e(ucfirst($patient->species)) . ($patient->breed ? ' · ' . e($patient->breed) : '') . '</small>' : '';
                return $name . $sub;
            })
            ->addColumn('owner_name', function ($row) {
                $owner = $row->owner;
                if (!$owner) return '—';
                $name   = e($owner->name ?? $owner->supplier_business_name ?? '—');
                $link   = '<a href="' . route('clinic.patient-contacts.show', $owner->id) . '" title="Open contact profile">' . $name . '</a>';
                $mobile = $owner->mobile ? '<br><small class="text-muted"><i class="fa fa-phone"></i> ' . e($owner->mobile) . '</small>' : '';
                return $link . $mobile;
            })
            ->addColumn('doctor_name', function ($row) {
                $doc  = optional($row->doctor)->name ?? '—';
                $dept = optional($row->department)->name;
                $deptBadge = $dept ? '<br><small class="text-muted">' . e($dept) . '</small>' : '';
                return e($doc) . $deptBadge;
            })
            ->addColumn('status_badge', function ($row) {
                $ol = ($row->has_overlap ?? false)
                    ? ' <span class="label label-warning" title="Overlap"><i class="fa fa-exclamation-triangle"></i></span>' : '';
                $label = ucfirst(str_replace('_', ' ', $row->status));
                $badge = '<span class="label label-' . $row->status_color . '" style="font-size:11px;padding:3px 8px;">' . $label . '</span>';

                // Inline status changer — lets staff update status without opening the appointment
                if (!auth()->user()->can('clinic.manage_appointments')) {
                    return $badge . $ol;
                }

                $statuses = [
                    'pending'     => 'Scheduled',
                    'confirmed'   => 'Confirmed',
                    'waiting'     => 'Waiting',
                    'in_progress' => 'In Consultation',
                    'completed'   => 'Completed',
                    'cancelled'   => 'Cancelled',
                    'no_show'     => 'No Show',
                ];
                $items = '';
                foreach ($statuses as $sv => $sl) {
                    $active = $sv === $row->status;
                    $items .= '<li' . ($active ? ' class="active"' : '') . '>'
                        . '<a href="#" class="vc-status-opt" data-id="' . $row->id . '" data-status="' . $sv . '">'
                        . ($active ? '<i class="fa fa-check"></i> ' : '<i class="fa fa-fw"></i> ') . $sl . '</a></li>';
                }

                return '<div class="dropdown vc-status-dd">'
                    . '<a href="#" class="vc-status-trigger" data-toggle="dropdown" title="Change status">'
                    . $badge . ' <i class="fa fa-caret-down" style="font-size:10px;color:#94a3b8;"></i></a>'
                    . '<ul class="dropdown-menu vc-status-menu">' . $items . '</ul>'
                    . '</div>' . $ol;
            })
            ->addColumn('type_badge', function ($row) {
                $typeColors = ['checkup' => '#3498db', 'vaccination' => '#27ae60', 'surgery' => '#e74c3c',
                               'grooming' => '#8e44ad', 'emergency' => '#e67e22', 'other' => '#7f8c8d'];
                $type  = $row->type ?? '—';
                $color = $typeColors[strtolower($type)] ?? '#7f8c8d';
                return '<span style="font-size:11px;padding:3px 8px;border-radius:10px;background:' . $color . '22;color:' . $color . ';border:1px solid ' . $color . '44;font-weight:700;">'
                    . ucfirst($type) . '</span>';
            })
            ->editColumn('ref_no', function ($row) {
                // Ref number is now a clickable shortcut — opens edit page when permitted, else show page.
                $canEdit = auth()->user()->can('clinic.manage_appointments');
                $url = $canEdit
                    ? route('clinic.appointments.edit', $row->id)
                    : route('clinic.appointments.show', $row->id);
                $title = $canEdit ? 'Open in edit page' : 'View appointment';
                return '<a href="' . $url . '" class="apt-ref-link" title="' . $title . '">' . e($row->ref_no) . '</a>';
            })
            ->addColumn('action', function ($row) {
                $show = '<a href="' . route('clinic.appointments.show', $row->id) . '" class="vc-act-btn vc-act-view" title="View appointment"><i class="fa fa-eye"></i></a>';
                $edit = auth()->user()->can('clinic.manage_appointments')
                    ? '<a href="' . route('clinic.appointments.edit', $row->id) . '" class="vc-act-btn vc-act-edit" title="Edit appointment"><i class="fa fa-pencil-alt"></i></a>'
                    : '';

                // Evaluate icon — only active when appointment is completed
                $evalBtn = '';
                if (auth()->user()->can('clinic.evaluate_doctor')) {
                    if ($row->status === 'completed') {
                        $evalBtn = '<button type="button" class="vc-act-btn vc-act-eval btn-evaluate-clinic" '
                            . 'data-id="' . $row->id . '" title="Evaluate clinic / doctor (patient feedback)">'
                            . '<i class="fa fa-star"></i></button>';
                    } else {
                        $evalBtn = '<span class="vc-act-btn vc-act-disabled" title="Evaluation available after the appointment is completed">'
                            . '<i class="fa fa-star"></i></span>';
                    }
                }

                return '<div class="vc-act-group">' . $show . $edit . $evalBtn . '</div>';
            })
            ->rawColumns(['action', 'status_badge', 'type_badge', 'pet_name', 'owner_name', 'doctor_name', 'datetime_col', 'ref_no'])
            ->make(true);
    }

    public function create()
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId  = request()->session()->get('user.business_id');
        $aptTypes    = ClcAppointmentType::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();
        $aptReasons  = ClcAppointmentReason::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();
        $allowOverlap = ClcModuleSetting::get($businessId, 'allow_overlap', '1') !== '0';

        // Locations that have at least one active department with at least one active doctor
        $locations = ClcClinicLocation::where('business_id', $businessId)->active()
            ->whereHas('departments', function ($q) {
                $q->where('status', 'active')->where(function ($dq) {
                    $dq->whereHas('doctors', fn($x) => $x->where('status', 'active'))
                       ->orWhereHas('assignedDoctors', fn($x) => $x->where('clc_doctors.status', 'active'));
                });
            })
            ->orderBy('name')->get();

        $preselectedContact = null;
        $preselectedPet     = null;
        if (request('patient_id')) {
            $preselectedPet = ClcPatient::where('business_id', $businessId)->find(request('patient_id'));
        }
        if (request('contact_id')) {
            $preselectedContact = \App\Contact::where('business_id', $businessId)->find(request('contact_id'));
        }
        if ($preselectedPet && !$preselectedContact) {
            $preselectedContact = \App\Contact::find($preselectedPet->contact_id);
        }

        $canChangeDoctor = auth()->user()->can('clinic.change_appointment_doctor');

        // SIFA P3-T2 · follow-up / transfer link: carry the parent encounter id
        // through the existing create form so store() can persist the link.
        $parentAppointmentId = request('parent_appointment_id') ? (int) request('parent_appointment_id') : null;

        return view('clinicCore::appointments.create', compact(
            'locations', 'aptTypes', 'aptReasons', 'allowOverlap',
            'preselectedContact', 'preselectedPet', 'canChangeDoctor', 'parentAppointmentId'
        ));
    }

    public function store(Request $request)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $request->validate([
            'contact_id'       => 'required|integer',
            'patient_id'           => 'required|integer',
            'appointment_date' => 'required|date',
            'appointment_time' => 'required',
        ]);

        $businessId = request()->session()->get('user.business_id');

        // Item 3.1 / 3.2 - reject bookings for blocked owners. Returns
        // 422 with a clear message the booking UI can surface to the user.
        if (app(\Modules\ClinicCore\Services\OwnerBlockService::class)
            ->isBlockedContact((int) $businessId, (int) $request->contact_id)) {
            return response()->json([
                'success' => false,
                'msg'     => 'This owner is currently blocked. Contact the admin to release the block before booking.',
            ], 422);
        }

        $refNo = 'APT-' . strtoupper(uniqid());

        // Core fields always present
        $data = $request->only(['contact_id', 'patient_id', 'doctor_id', 'department_id',
                                'appointment_date', 'appointment_time', 'duration_minutes',
                                'reason', 'type', 'notes']);
        $data['business_id'] = $businessId;
        $data['ref_no']      = $refNo;
        $data['status']      = 'pending';

        // Preferred-doctor enforcement: users without the change permission are
        // forced onto the pet's effective preferred doctor when one is configured.
        if (!auth()->user()->can('clinic.change_appointment_doctor')) {
            $patient = ClcPatient::where('business_id', $businessId)->find($request->patient_id);
            $preferredDoctorId = $patient ? $patient->effectivePreferredDoctorId() : null;
            if ($preferredDoctorId) {
                $data['doctor_id'] = $preferredDoctorId;
            }
        }

        // Optional columns — only include if column exists in DB
        $tbl = (new ClcAppointment)->getTable();
        foreach (['weight_kg', 'location_id', 'schedule_id'] as $col) {
            if (Schema::hasColumn($tbl, $col) && $request->filled($col)) {
                $data[$col] = $request->input($col);
            }
        }
        // SIFA P3-T7 — booking source (staff form defaults to walk_in).
        if (Schema::hasColumn($tbl, 'source')) {
            $allowedSources = ['walk_in', 'online', 'phone', 'referral', 'internal'];
            $src = $request->input('source', 'walk_in');
            $data['source'] = in_array($src, $allowedSources, true) ? $src : 'walk_in';
        }
        // SIFA P3-T2 · follow-up / transfer link carried from the source encounter.
        if (Schema::hasColumn($tbl, 'parent_appointment_id') && $request->filled('parent_appointment_id')) {
            $data['parent_appointment_id'] = (int) $request->input('parent_appointment_id');
        }
        // JSON multi-select fields
        if (Schema::hasColumn($tbl, 'type_ids') && $request->filled('type_ids')) {
            $data['type_ids'] = $request->input('type_ids');
        }
        if (Schema::hasColumn($tbl, 'reason_ids') && $request->filled('reason_ids')) {
            $data['reason_ids'] = $request->input('reason_ids');
        }

        // Overlap detection
        $overlapAllowed = config('cliniccore.allow_overlap', true);
        $hasOverlap     = false;
        if (!$overlapAllowed && $request->filled('doctor_id') && $request->filled('appointment_date') && $request->filled('appointment_time')) {
            $aptStart = Carbon::parse($request->appointment_date . ' ' . $request->appointment_time);
            $aptEnd   = $aptStart->copy()->addMinutes($request->duration_minutes ?? 30);
            $hasOverlap = ClcAppointment::where('business_id', $businessId)
                ->where('doctor_id', $request->doctor_id)
                ->whereDate('appointment_date', $request->appointment_date)
                ->whereNotIn('status', ['cancelled', 'no_show'])
                ->get()
                ->contains(function ($existing) use ($aptStart, $aptEnd) {
                    $eStart = Carbon::parse($existing->appointment_date->format('Y-m-d') . ' ' . $existing->appointment_time);
                    $eEnd   = $eStart->copy()->addMinutes($existing->duration_minutes ?? 30);
                    return $aptStart->lt($eEnd) && $aptEnd->gt($eStart);
                });
        }
        if (Schema::hasColumn($tbl, 'has_overlap')) {
            $data['has_overlap'] = $hasOverlap;
        }

        // Tag the appointment with a specialty (explicit picker value, else the
        // patient's default) so specialty exam-sheets and driver fragments apply.
        if (Schema::hasColumn($tbl, 'specialty_id')) {
            $specialtyId = $request->input('specialty_id');
            if (!$specialtyId && !empty($data['patient_id'])) {
                $specialtyId = ClcPatient::where('id', $data['patient_id'])->value('default_specialty_id');
            }
            $data['specialty_id'] = $specialtyId ?: null;
        }

        $appointment = ClcAppointment::create($data);
        $this->drmLog('create', $appointment);

        $msg = __('clinicCore::lang.saved_successfully');
        if ($hasOverlap) {
            $msg .= ' ⚠️ Warning: this overlaps with another appointment for the same doctor.';
        }

        if ($request->wantsJson() || $request->ajax()) {
            return response()->json(['success' => true, 'msg' => $msg, 'has_overlap' => $hasOverlap, 'appointment_id' => $appointment->id]);
        }

        return redirect()->route('clinic.appointments.show', $appointment->id)
            ->with('status', ['success' => 1, 'msg' => $msg]);
    }

    public function show($id)
    {
        if (!auth()->user()->can('clinic.view_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId  = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::with([
            'pet', 'owner', 'doctor', 'department',
        ])->where('business_id', $businessId)->findOrFail($id);

        // Load optional relations safely; set empty fallbacks so the view never re-triggers a failing lazy load
        try { $appointment->load('location'); }                 catch (\Throwable $e) {}
        try { $appointment->load('pet.conditions.condition'); } catch (\Throwable $e) {}
        try { $appointment->load('appointmentNotes.user'); }     catch (\Throwable $e) {}
        try { $appointment->load('vaccinations.vaccineType'); }  catch (\Throwable $e) {}
        try { $appointment->load('prescriptions.doctor'); }     catch (\Throwable $e) {}
        try { $appointment->load('documents.uploader'); }       catch (\Throwable $e) {}
        try { $appointment->load('statusLogs.changedBy'); }     catch (\Throwable $e) {}

        foreach (['location', 'appointmentNotes', 'vaccinations', 'prescriptions', 'documents'] as $rel) {
            if (!$appointment->relationLoaded($rel)) {
                $appointment->setRelation($rel, $rel === 'location' ? null : collect());
            }
        }
        if ($appointment->pet && !$appointment->pet->relationLoaded('conditions')) {
            $appointment->pet->setRelation('conditions', collect());
        }

        $soapChipLimit = (int) ClcModuleSetting::get($businessId, 'soap_chip_limit', 15);
        $aptTypes      = ClcAppointmentType::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();
        $aptReasons    = ClcAppointmentReason::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();

        // Edit-lock state for completed appointments + this user's latest request
        $editGate    = $this->appointmentEditGate($appointment);
        $myEditRequest = ClcAppointmentEditRequest::where('appointment_id', $appointment->id)
            ->where('requested_by', auth()->id())
            ->latest()->first();

        // Doctor-change request state
        $canChangeDoctor     = auth()->user()->can('clinic.change_appointment_doctor');
        $pendingDoctorChange = \Modules\ClinicCore\Entities\ClcDoctorChangeRequest::where('appointment_id', $appointment->id)
            ->where('status', 'pending')->latest()->first();
        $doctors = ClcDoctor::where('business_id', $businessId)->active()->orderBy('name')->get();

        $this->drmLog('view', $appointment);
        return view('clinicCore::appointments.show', compact(
            'appointment', 'soapChipLimit', 'aptTypes', 'aptReasons', 'editGate', 'myEditRequest',
            'canChangeDoctor', 'pendingDoctorChange', 'doctors'
        ));
    }

    public function edit($id)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId  = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::where('business_id', $businessId)->findOrFail($id);

        // Completed appointments are locked unless the user has the permission
        // or holds an approved (unused) edit request.
        $gate = $this->appointmentEditGate($appointment);
        if (!$gate['allowed']) {
            $msg = $gate['pending']
                ? 'This appointment is completed. Your edit request is awaiting approval.'
                : 'This appointment is completed and locked. Please request edit access.';
            return redirect()->route('clinic.appointments.show', $id)
                ->with('status', ['success' => 0, 'msg' => $msg]);
        }
        $editViaRequest = $gate['request'];

        $aptTypes    = ClcAppointmentType::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();
        $aptReasons  = ClcAppointmentReason::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get();
        $allowOverlap = ClcModuleSetting::get($businessId, 'allow_overlap', '1') !== '0';

        $locations = ClcClinicLocation::where('business_id', $businessId)->active()
            ->whereHas('departments', function ($q) {
                $q->where('status', 'active')->where(function ($dq) {
                    $dq->whereHas('doctors', fn($x) => $x->where('status', 'active'))
                       ->orWhereHas('assignedDoctors', fn($x) => $x->where('clc_doctors.status', 'active'));
                });
            })
            ->orderBy('name')->get();

        $canChangeDoctor = auth()->user()->can('clinic.change_appointment_doctor');

        return view('clinicCore::appointments.create', compact(
            'appointment', 'locations', 'aptTypes', 'aptReasons', 'allowOverlap', 'editViaRequest', 'canChangeDoctor'
        ));
    }

    public function update(Request $request, $id)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $request->validate([
            'appointment_date' => 'required|date',
            'appointment_time' => 'required',
        ]);

        $businessId  = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::where('business_id', $businessId)->findOrFail($id);

        // Enforce the post-completion edit lock
        $gate = $this->appointmentEditGate($appointment);
        if (!$gate['allowed']) {
            $msg = $gate['pending']
                ? 'This appointment is completed — your edit request is awaiting approval.'
                : 'This appointment is completed and locked. Please request edit access first.';
            return redirect()->route('clinic.appointments.show', $id)
                ->with('status', ['success' => 0, 'msg' => $msg]);
        }

        $original    = $appointment->getOriginal();
        $oldStatus   = $appointment->status;
        $fields      = $request->only(['doctor_id', 'department_id', 'appointment_date', 'appointment_time',
                                       'duration_minutes', 'weight_kg', 'reason', 'type', 'status', 'notes', 'doctor_notes']);

        // Preferred-doctor enforcement: users without the change permission cannot
        // change the assigned doctor — it must go through a doctor-change request.
        if (!auth()->user()->can('clinic.change_appointment_doctor')) {
            unset($fields['doctor_id']);
        }

        // If the status changed through the edit form, capture time-tracking timestamps too
        if (isset($fields['status']) && $fields['status'] !== $oldStatus) {
            $fields = array_merge($fields, $this->applyStatusTimestamps($appointment, $fields['status']));
        }

        $appointment->update($fields);
        $dirty = array_keys($appointment->getChanges());

        if (in_array('status', $dirty, true)) {
            $this->logStatusChange($appointment, $oldStatus, $appointment->status);
        }

        // A single-use approved edit request is consumed once the edit is saved
        if ($gate['request']) {
            $gate['request']->update(['consumed_at' => now()]);
            $this->drmLog('update', $gate['request'], ['note' => 'Edit request consumed for appointment #' . $appointment->id]);
        }

        $this->drmLog('update', $appointment, [
            'before' => array_intersect_key($original, array_flip($dirty)),
            'after'  => array_intersect_key($appointment->getAttributes(), array_flip($dirty)),
            'fields' => $dirty,
            'note'   => $gate['request'] ? 'Edited under approved request #' . $gate['request']->id : null,
        ]);

        return redirect()->route('clinic.appointments.show', $id)
            ->with('status', ['success' => 1, 'msg' => __('clinicCore::lang.updated_successfully')]);
    }

    // ── Time-tracking & edit-lock helpers ────────────────────────────────────

    /**
     * Compute waiting / consultation timestamp columns for a status transition.
     * Returns only the columns that should change (does not persist).
     */
    private function applyStatusTimestamps(ClcAppointment $a, string $newStatus): array
    {
        $now = now();
        $u   = [];

        if ($newStatus === 'waiting' && empty($a->arrived_at)) {
            $u['arrived_at'] = $now;
        }

        if ($newStatus === 'in_progress' && empty($a->consultation_started_at)) {
            $u['consultation_started_at'] = $now;
            if ($a->arrived_at && empty($a->waiting_minutes)) {
                $u['waiting_minutes'] = $a->arrived_at->diffInMinutes($now);
            }
        }

        if ($newStatus === 'completed' && empty($a->consultation_ended_at)) {
            $u['consultation_ended_at'] = $now;
            $start = $a->consultation_started_at ?? ($u['consultation_started_at'] ?? null);
            if ($start && empty($a->consultation_minutes)) {
                $u['consultation_minutes'] = Carbon::parse($start)->diffInMinutes($now);
            }
        }

        return $u;
    }

    /** Append a row to the appointment status-change audit log. */
    private function logStatusChange(ClcAppointment $a, ?string $from, string $to): void
    {
        try {
            ClcAppointmentStatusLog::create([
                'business_id'    => $a->business_id,
                'appointment_id' => $a->id,
                'from_status'    => $from,
                'to_status'      => $to,
                'changed_by'     => auth()->id(),
                'changed_at'     => now(),
            ]);
        } catch (\Throwable $e) {
            \Illuminate\Support\Facades\Log::warning('ClcStatusLog failed: ' . $e->getMessage());
        }
    }

    /**
     * Decide whether the current user may edit this appointment.
     * Completed appointments are locked unless the user has the bypass
     * permission or holds an approved, unused edit request.
     *
     * @return array{allowed:bool, request:?ClcAppointmentEditRequest, pending:bool}
     */
    private function appointmentEditGate(ClcAppointment $a): array
    {
        if ($a->status !== 'completed') {
            return ['allowed' => true, 'request' => null, 'pending' => false];
        }
        if (auth()->user()->can('clinic.edit_completed_appointments')) {
            return ['allowed' => true, 'request' => null, 'pending' => false];
        }

        $usable = ClcAppointmentEditRequest::where('appointment_id', $a->id)
            ->where('requested_by', auth()->id())
            ->usable()->latest()->first();
        if ($usable) {
            return ['allowed' => true, 'request' => $usable, 'pending' => false];
        }

        $pending = ClcAppointmentEditRequest::where('appointment_id', $a->id)
            ->where('requested_by', auth()->id())
            ->pending()->exists();

        return ['allowed' => false, 'request' => null, 'pending' => $pending];
    }

    public function updateStatus(Request $request, $id)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $request->validate(['status' => 'required|in:pending,waiting,confirmed,in_progress,completed,cancelled,no_show']);
        $businessId  = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::where('business_id', $businessId)->findOrFail($id);
        $oldStatus   = $appointment->status;
        $newStatus   = $request->status;

        // Item #2 - block the completion transition unless at least one
        // vital sign has been recorded. Avoids "closing" a visit with no
        // clinical data (a frequent reception-vs-doctor handover gap).
        // Skipped on no_show / cancelled since those legitimately have
        // no vitals.
        if ($newStatus === 'completed' && $oldStatus !== 'completed') {
            $hasAnyVital = (float) $appointment->weight_kg
                        || (float) $appointment->temperature
                        || (int)   $appointment->heart_rate
                        || (int)   $appointment->respiratory_rate;
            if (!$hasAnyVital) {
                return response()->json([
                    'success' => false,
                    'msg'     => 'Cannot mark this appointment as Completed without at least one vital sign recorded (weight, temperature, heart rate, or respiratory rate). Please fill the vitals first.',
                    'reason'  => 'no_vitals',
                ], 422);
            }
        }

        // Capture waiting / consultation timestamps from this transition
        $timestamps = $this->applyStatusTimestamps($appointment, $newStatus);
        $appointment->update(array_merge(['status' => $newStatus], $timestamps));

        if ($oldStatus !== $newStatus) {
            $this->logStatusChange($appointment, $oldStatus, $newStatus);
        }

        $this->drmLog('update', $appointment, [
            'before' => ['status' => $oldStatus],
            'after'  => array_merge(['status' => $newStatus], $timestamps),
            'fields' => array_merge(['status'], array_keys($timestamps)),
        ]);

        // Auto-schedule the next visit when this appointment is marked completed
        $autoNextId = null;
        if ($request->status === 'completed' && $appointment->next_visit_date && !$appointment->next_appointment_id) {
            $autoNextId = optional($this->createNextAppointmentFrom($appointment))->id;
        }

        // SIFA P3-T7 — a freed slot (cancellation) may match waiting patients.
        // Non-blocking: surface a notice + link and fire slot_available via
        // MedNotify (fail-soft). Never disrupts the status change itself.
        $waitlistNotice = null;
        if ($newStatus === 'cancelled' && $oldStatus !== 'cancelled') {
            try {
                $matches = app(\Modules\ClinicCore\Services\WaitlistMatchService::class)
                    ->notifyMatches($appointment);
                if ($matches > 0 && auth()->user()->can('clinic.waitlist.manage')) {
                    $waitlistNotice = [
                        'count' => $matches,
                        'msg'   => trans_choice(
                            '{1}:count waiting patient matches this freed slot.|[2,*]:count waiting patients match this freed slot.',
                            $matches,
                            ['count' => $matches]
                        ),
                        'url'   => route('clinic.waitlist.index'),
                    ];
                }
            } catch (\Throwable $e) {
                \Illuminate\Support\Facades\Log::warning('ClinicCore waitlist notice skipped: ' . $e->getMessage());
            }
        }

        return response()->json([
            'success'             => true,
            'msg'                 => __('clinicCore::lang.updated_successfully'),
            'next_appointment_id' => $autoNextId,
            'waitlist_notice'     => $waitlistNotice,
        ]);
    }

    public function destroy($id)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId  = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::where('business_id', $businessId)->findOrFail($id);
        $snap = $appointment->getAttributes();
        $this->drmLog('delete', $appointment, ['before' => $this->_drmFilterAttrs($snap)]);
        $appointment->delete();

        return response()->json(['success' => true, 'msg' => __('clinicCore::lang.deleted_successfully')]);
    }

    // ── Private helpers ───────────────────────────────────────────────────────

    /** Booked time ranges for a doctor on a specific date */
    private function getBookedRanges(int $doctorId, string $date, int $businessId): array
    {
        return ClcAppointment::where('business_id', $businessId)
            ->where('doctor_id', $doctorId)
            ->whereDate('appointment_date', $date)
            ->whereNotIn('status', ['cancelled', 'no_show'])
            ->get(['appointment_time', 'duration_minutes'])
            ->map(fn($a) => [
                'start' => Carbon::parse($date . ' ' . $a->appointment_time),
                'end'   => Carbon::parse($date . ' ' . $a->appointment_time)->addMinutes($a->duration_minutes ?? 30),
            ])
            ->toArray();
    }

    /** True if slot [slotStart, slotEnd) overlaps any booked range */
    private function slotIsBooked(Carbon $slotStart, Carbon $slotEnd, array $bookedRanges): bool
    {
        foreach ($bookedRanges as $range) {
            if ($slotStart->lt($range['end']) && $slotEnd->gt($range['start'])) {
                return true;
            }
        }
        return false;
    }

    /** Schedules for a doctor on a given day, applying dept-priority logic */
    private function getSchedulesForDoctor(int $doctorId, ?int $deptId, int $dayOfWeek)
    {
        $base = ClcDoctorSchedule::where('doctor_id', $doctorId)->active()->where('day_of_week', $dayOfWeek);
        if ($deptId) {
            $schedules = (clone $base)->where('department_id', $deptId)->get();
            if ($schedules->isEmpty()) {
                $schedules = (clone $base)->whereNull('department_id')->get();
            }
        } else {
            $schedules = (clone $base)->whereNull('department_id')->get();
        }
        return $schedules;
    }

    /** True if doctor has ≥1 available slot on a date (respects overlap setting) */
    private function doctorHasFreeSlot(int $doctorId, string $date, int $businessId, ?int $deptId, bool $allowOverlap): bool
    {
        // SIFA P3-T7 — go through the shared engine so effective ranges, breaks,
        // exceptions and capacity are all honoured here too.
        $slots = app(\Modules\ClinicCore\Services\Booking\ScheduleAvailabilityService::class)
            ->slotsForDoctor($businessId, $doctorId, Carbon::parse($date), $deptId);
        if (empty($slots)) return false;
        if ($allowOverlap) return true;

        foreach ($slots as $slot) {
            if ($slot['available']) return true;
        }
        return false;
    }

    /** Doctor IDs assigned to a department (primary + pivot) */
    private function doctorIdsForDept(int $businessId, int $deptId)
    {
        return ClcDoctor::where('business_id', $businessId)->where('status', 'active')
            ->where(function ($q) use ($deptId) {
                $q->whereHas('departments', fn($dq) => $dq->where('clc_departments.id', $deptId))
                  ->orWhere('department_id', $deptId);
            })
            ->pluck('id');
    }

    // ── AJAX endpoints ────────────────────────────────────────────────────────

    /** AJAX: all active locations, each with has_available flag for the given date */
    public function locationsByDate(Request $request)
    {
        $businessId   = request()->session()->get('user.business_id');
        $date         = $request->date;
        $allowOverlap = ClcModuleSetting::get($businessId, 'allow_overlap', '1') !== '0';

        $locations = ClcClinicLocation::where('business_id', $businessId)->active()->orderBy('name')->get();

        return response()->json($locations->map(function ($loc) use ($businessId, $date, $allowOverlap) {
            $hasAvailable = false;

            if ($date) {
                $depts = ClcDepartment::where('business_id', $businessId)
                    ->where('location_id', $loc->id)->where('status', 'active')->get(['id']);

                foreach ($depts as $dept) {
                    foreach ($this->doctorIdsForDept($businessId, $dept->id) as $docId) {
                        if ($this->doctorHasFreeSlot($docId, $date, $businessId, $dept->id, $allowOverlap)) {
                            $hasAvailable = true;
                            break 2;
                        }
                    }
                }
            } else {
                $hasAvailable = true;
            }

            return [
                'id'            => $loc->id,
                'name'          => $loc->name,
                'address'       => $loc->address ?? '',
                'phone'         => $loc->phone   ?? '',
                'email'         => $loc->email   ?? '',
                'has_available' => $hasAvailable,
            ];
        }));
    }

    /** AJAX: all departments for a location, each with has_available flag */
    public function deptsByLocation(Request $request)
    {
        $businessId   = request()->session()->get('user.business_id');
        $date         = $request->date;
        $allowOverlap = ClcModuleSetting::get($businessId, 'allow_overlap', '1') !== '0';

        $query = ClcDepartment::where('business_id', $businessId)->active();
        if ($request->location_id) {
            $query->where('location_id', $request->location_id);
        }
        $departments = $query->orderBy('name')->get(['id', 'name']);

        return response()->json($departments->map(function ($dept) use ($businessId, $date, $allowOverlap) {
            $hasAvailable = true;
            if ($date) {
                $hasAvailable = false;
                foreach ($this->doctorIdsForDept($businessId, $dept->id) as $docId) {
                    if ($this->doctorHasFreeSlot($docId, $date, $businessId, $dept->id, $allowOverlap)) {
                        $hasAvailable = true;
                        break;
                    }
                }
            }
            return ['id' => $dept->id, 'name' => $dept->name, 'has_available' => $hasAvailable];
        }));
    }

    /** AJAX: all doctors for a department, each with has_available flag */
    public function doctorsByDept(Request $request)
    {
        $businessId   = request()->session()->get('user.business_id');
        $deptId       = $request->department_id ? (int) $request->department_id : null;
        $locationId   = $request->location_id;
        $date         = $request->date;
        $allowOverlap = ClcModuleSetting::get($businessId, 'allow_overlap', '1') !== '0';

        $query = ClcDoctor::where('business_id', $businessId)->active();

        if ($deptId) {
            $query->where(function ($q) use ($deptId) {
                $q->whereHas('departments', fn($dq) => $dq->where('clc_departments.id', $deptId))
                  ->orWhere('department_id', $deptId);
            });
        } elseif ($locationId) {
            $deptIds = ClcDepartment::where('location_id', $locationId)->pluck('id');
            $query->where(function ($q) use ($deptIds) {
                $q->whereHas('departments', fn($dq) => $dq->whereIn('clc_departments.id', $deptIds))
                  ->orWhereIn('department_id', $deptIds);
            });
        }

        $doctors = $query->orderBy('name')->get(['id', 'name', 'specialization']);

        return response()->json($doctors->map(function ($doc) use ($businessId, $date, $deptId, $allowOverlap) {
            $hasAvailable = $date
                ? $this->doctorHasFreeSlot($doc->id, $date, $businessId, $deptId, $allowOverlap)
                : true;
            return [
                'id'            => $doc->id,
                'text'          => $doc->name . ($doc->specialization ? ' (' . $doc->specialization . ')' : ''),
                'has_available' => $hasAvailable,
            ];
        }));
    }

    /** AJAX: slot pairs {start, end, booked, available, schedule_id, duration} */
    public function availableSlots(Request $request)
    {
        $businessId   = request()->session()->get('user.business_id');
        $doctorId     = (int) $request->doctor_id;
        $deptId       = $request->department_id ? (int) $request->department_id : null;
        $date         = $request->date;
        $allowOverlap = ClcModuleSetting::get($businessId, 'allow_overlap', '1') !== '0';

        if (!$doctorId || !$date) return response()->json([]);

        // SIFA P3-T7 — slots now come from the shared ScheduleAvailabilityService
        // (single source of truth) so the staff booking screen honours effective
        // ranges, break periods, exceptions and per-slot capacity / overbooking
        // exactly like the public BookingDriver. allow_overlap keeps its legacy
        // meaning: when on, a full slot stays selectable (with a warning).
        $slots = app(\Modules\ClinicCore\Services\Booking\ScheduleAvailabilityService::class)
            ->slotsForDoctor($businessId, $doctorId, Carbon::parse($date), $deptId);

        $result = array_map(function ($slot) use ($allowOverlap) {
            $booked = !$slot['available']; // capacity reached
            return [
                'start'       => $slot['start'],
                'end'         => $slot['end'],
                'booked'      => $booked,
                'available'   => $slot['available'] || $allowOverlap,
                'schedule_id' => $slot['schedule_id'],
                'duration'    => $slot['duration'],
            ];
        }, $slots);

        return response()->json(array_values($result));
    }

    public function uploadDocument(Request $request, $id)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            return response()->json(['success' => false, 'msg' => 'Unauthorized'], 403);
        }

        $request->validate([
            'file'          => 'required|file|max:10240',
            'document_name' => 'required|string|max:255',
            'document_type' => 'required|string',
        ]);

        $businessId  = request()->session()->get('user.business_id');
        ClcAppointment::where('business_id', $businessId)->findOrFail($id);

        $file = $request->file('file');
        $path = $file->store("clc_documents/{$businessId}/{$id}", 'public');

        $doc = ClcAppointmentDocument::create([
            'business_id'    => $businessId,
            'appointment_id' => $id,
            'user_id'        => auth()->id(),
            'document_name'  => $request->document_name,
            'document_type'  => $request->document_type,
            'description'    => $request->description,
            'file_name'      => $file->getClientOriginalName(),
            'file_path'      => $path,
            'file_size'      => $file->getSize(),
            'mime_type'      => $file->getMimeType(),
        ]);

        $user = auth()->user();
        $dateFormat = session('business.date_format', 'd M Y');
        return response()->json([
            'success' => true,
            'msg'     => 'Document uploaded.',
            'doc'     => [
                'id'          => $doc->id,
                'name'        => $doc->document_name,
                'type_label'  => $doc->type_label,
                'file_name'   => $doc->file_name,
                'size_kb'     => $doc->file_size_kb,
                'url'         => $doc->url,
                'mime'        => $doc->mime_type,
                'is_image'    => str_starts_with($doc->mime_type ?? '', 'image/'),
                'description' => $doc->description,
                'uploaded_by' => trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')),
                'created_at'  => $doc->created_at->format($dateFormat),
            ],
        ]);
    }

    public function deleteDocument(Request $request, $id, $docId)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            return response()->json(['success' => false, 'msg' => 'Unauthorized'], 403);
        }

        $businessId = request()->session()->get('user.business_id');
        $doc = ClcAppointmentDocument::where('appointment_id', $id)
                                     ->where('business_id', $businessId)
                                     ->findOrFail($docId);
        Storage::disk('public')->delete($doc->file_path);
        $doc->delete();

        return response()->json(['success' => true, 'msg' => 'Document deleted.']);
    }

    /**
     * Item 1.3 - GET /vc/appointments/{id}/vital-ranges
     * Returns the reference range for this pet so the consultation form
     * can show inline warnings as the doctor types vitals. Lightweight -
     * no auth-heavy gate; just the existing business-scope check.
     */
    public function vitalRanges($id)
    {
        $businessId = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::where('business_id', $businessId)->findOrFail($id);
        $patient = $appointment->pet;
        $species = $patient ? strtolower($patient->species ?? '') : null;
        $ageYears = ($patient && $patient->dob)
            ? (int) \Carbon\Carbon::parse($patient->dob)->diffInYears(now())
            : null;

        $range = \Modules\ClinicCore\Entities\ClcVitalRange::resolve(
            (int) $businessId, $species, $ageYears
        );
        if (!$range) {
            // No species range (human clinic) → serve fixed adult-human bands so
            // the live vitals colouring still works for the human "constants".
            return response()->json([
                'success'   => true,
                'has_range' => true,
                'species'   => null,
                'life_stage'=> 'human',
                'ranges'    => \Modules\ClinicCore\Entities\ClcVitalRange::humanBands(),
            ]);
        }
        return response()->json([
            'success'   => true,
            'has_range' => true,
            'species'   => $species,
            'life_stage'=> $range->life_stage,
            'ranges'    => [
                'temperature'      => ['low' => $range->temperature_low,      'high' => $range->temperature_high,      'unit' => '°C'],
                'heart_rate'       => ['low' => $range->heart_rate_low,       'high' => $range->heart_rate_high,       'unit' => 'bpm'],
                'respiratory_rate' => ['low' => $range->respiratory_rate_low, 'high' => $range->respiratory_rate_high, 'unit' => '/min'],
                'weight_kg'        => ['low' => $range->weight_low_kg,        'high' => $range->weight_high_kg,        'unit' => 'kg'],
            ],
        ]);
    }

    /**
     * Item 1.3 - score the appointment's vitals against the reference
     * range for this pet's species/age and update the critical-count
     * columns + per-vital JSON snapshot. Also picks up critical
     * exam-findings count from clc_appointment_exam_findings (Phase A).
     * Safe to call after any vital/finding change.
     */
    protected function recomputeVitalCriticalFlags(ClcAppointment $appointment): void
    {
        if (!\Schema::hasColumn('clc_appointments', 'vitals_critical_count')) return;
        $patient = $appointment->pet;
        $species = $patient ? strtolower($patient->species ?? '') : null;
        $ageYears = ($patient && $patient->dob)
            ? (int) \Carbon\Carbon::parse($patient->dob)->diffInYears(now())
            : null;

        $vitals = [
            'temperature'      => $appointment->temperature,
            'heart_rate'       => $appointment->heart_rate,
            'respiratory_rate' => $appointment->respiratory_rate,
            'weight_kg'        => $appointment->weight_kg,
        ];

        $range = \Modules\ClinicCore\Entities\ClcVitalRange::resolve(
            (int) $appointment->business_id, $species, $ageYears
        );
        $annotation = $range ? $range->annotate($vitals) : [];

        // Human clinics have no species range — fall back to fixed human bands
        // and include the human "constants" (BP / SpO2 / glucose / BMI).
        if (!$range) {
            $annotation = \Modules\ClinicCore\Entities\ClcVitalRange::annotateHuman([
                'temperature'      => $appointment->temperature,
                'heart_rate'       => $appointment->heart_rate,
                'respiratory_rate' => $appointment->respiratory_rate,
                'systolic_bp'      => $appointment->systolic_bp ?? null,
                'diastolic_bp'     => $appointment->diastolic_bp ?? null,
                'spo2'             => $appointment->spo2 ?? null,
                'blood_glucose'    => $appointment->blood_glucose ?? null,
                'bmi'              => $appointment->bmi ?? null,
            ]);
        }

        $critCount = 0; $abnCount = 0;
        foreach ($annotation as $sev) {
            if ($sev === 'critical')                $critCount++;
            elseif (in_array($sev, ['low','high'])) $abnCount++;
        }

        // Findings count from the Phase A structured-exam table - cheap
        // to recompute and keeps the column accurate even if exam findings
        // were saved without going through the consultation save flow.
        $findingsCrit = 0;
        if (\Schema::hasTable('clc_appointment_exam_findings')) {
            $findingsCrit = (int) \DB::table('clc_appointment_exam_findings')
                ->where('appointment_id', $appointment->id)
                ->where('is_critical', 1)
                ->count();
        }

        $appointment->forceFill([
            'vitals_critical_count'   => $critCount,
            'vitals_abnormal_count'   => $abnCount,
            'vitals_critical_json'    => $annotation ? json_encode($annotation) : null,
            'findings_critical_count' => $findingsCrit,
            'last_critical_check_at'  => now(),
        ])->save();
    }

    /**
     * SIFA P3-T3 · ABAC field-group gate. When MedOrganization is installed,
     * consult MedAccess::fieldGroupEditable() for the given group (e.g.
     * 'assessment' / 'vitals'). Fail-open (true) when the module or class is
     * absent so stock installs behave exactly as before.
     */
    private function medFieldGroupEditable(string $group): bool
    {
        try {
            $mu = app(\App\Utils\ModuleUtil::class);
            if (!$mu->isModuleInstalled('MedOrganization')) {
                return true;
            }
            $accessClass = '\Modules\MedOrganization\Services\MedAccess';
            if (!class_exists($accessClass)) {
                return true;
            }
            return (bool) app($accessClass)->fieldGroupEditable(auth()->user(), $group);
        } catch (\Throwable $e) {
            return true; // fail-open
        }
    }

    public function saveConsultation(Request $request, $id)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId = request()->session()->get('user.business_id');
        $fields = $request->only(['weight_kg', 'temperature', 'heart_rate', 'respiratory_rate',
                                   'systolic_bp', 'diastolic_bp', 'spo2', 'blood_glucose', 'bmi',
                                   'soap_subjective', 'soap_objective', 'soap_assessment', 'soap_plan', 'doctor_notes']);
        // Guard: the new constants columns may not exist on older installs.
        foreach (['systolic_bp', 'diastolic_bp', 'spo2', 'blood_glucose', 'bmi'] as $vc) {
            if (!Schema::hasColumn('clc_appointments', $vc)) { unset($fields[$vc]); }
        }

        // SIFA P3-T3 · ABAC field-group gate (MedOrganization, fail-open).
        // Nurse-vitals-only / billing-can't-touch-assessment style overrides:
        // when the module is installed and denies the group, drop those keys
        // from the update so the rest of the consultation still saves.
        if (!$this->medFieldGroupEditable('assessment')) {
            unset($fields['soap_assessment']);
        }
        if (!$this->medFieldGroupEditable('vitals')) {
            foreach (['weight_kg', 'temperature', 'heart_rate', 'respiratory_rate',
                      'systolic_bp', 'diastolic_bp', 'spo2', 'blood_glucose', 'bmi'] as $vc) {
                unset($fields[$vc]);
            }
        }

        // SIFA P3-T3 · signed & locked SOAP notes are read-only here — edits go
        // through the amendment workflow (ClcEncounterController::amendNote).
        if (Schema::hasColumn('clc_appointments', 'soap_locked')) {
            $lockCheck = ClcAppointment::where('business_id', $businessId)->find($id);
            if ($lockCheck && $lockCheck->soap_locked) {
                foreach (['soap_subjective', 'soap_objective', 'soap_assessment', 'soap_plan',
                          'soap_investigations', 'soap_lab_investigations', 'soap_exam_investigations',
                          'soap_plan_meds_json'] as $soapCol) {
                    unset($fields[$soapCol]);
                }
            }
        }
        // Specialty-specific extra vitals (JSON map).
        if (Schema::hasColumn('clc_appointments', 'extra_vitals') && $request->has('extra_vitals')) {
            $extra = (array) $request->input('extra_vitals', []);
            $extra = array_filter($extra, fn($v) => $v !== '' && $v !== null);
            $fields['extra_vitals'] = $extra ? json_encode($extra) : null;
        }
        foreach (['soap_investigations', 'soap_lab_investigations', 'soap_exam_investigations', 'soap_plan_meds_json',
                  'exam_results',
                  'next_visit_date', 'next_visit_time', 'next_visit_doctor_id', 'next_visit_department_id',
                  'next_visit_reason', 'next_visit_notes',
                  'next_visit_type_ids', 'next_visit_reason_ids'] as $col) {
            if (Schema::hasColumn('clc_appointments', $col) && $request->has($col)) {
                $val = $request->input($col);
                // empty strings → null for nullable cols
                if (in_array($col, ['next_visit_date', 'next_visit_time', 'next_visit_doctor_id', 'next_visit_department_id',
                                    'next_visit_reason', 'next_visit_notes']) && $val === '') {
                    $val = null;
                }
                // JSON-string arrays from the front-end → decode for the array cast on the model
                if (in_array($col, ['next_visit_type_ids', 'next_visit_reason_ids', 'exam_results']) && is_string($val) && $val !== '') {
                    $decoded = json_decode($val, true);
                    $val = is_array($decoded) ? $decoded : null;
                }
                $fields[$col] = $val;
            }
        }
        $appointment = ClcAppointment::where('business_id', $businessId)->findOrFail($id);
        $appointment->update($fields);

        // Item 1.3 - score vitals against the reference range for this
        // species/age and persist a critical-count + per-vital severity
        // snapshot so the Medical Critical Check report (4.1) can query
        // a flat WHERE without re-evaluating ranges. Fails soft: any
        // exception leaves the appointment saved without critical flags.
        try {
            $this->recomputeVitalCriticalFlags($appointment);
        } catch (\Throwable $e) {
            \Log::warning('ClinicCore vital critical-check failed: ' . $e->getMessage());
        }

        // SIFA P3-T4 — feed the clc_vitals trend table from the legacy vitals
        // grid on every consultation save. This replaces the (now removed)
        // duplicate extended-vitals card in the encounter panel: the single
        // vitals-entry surface (this form) keeps both the legacy clc_appointments
        // columns AND the trend table in sync. Idempotent per appointment:
        // update the latest clc_vitals row for this appointment, else insert one.
        try {
            $this->syncVitalsTrend($request, $appointment, $businessId);
        } catch (\Throwable $e) {
            \Log::warning('ClinicCore clc_vitals trend sync failed: ' . $e->getMessage());
        }

        // Two-way sync: push completed lab/investigation results into Medical Reports
        if (array_key_exists('exam_results', $fields)) {
            try {
                app(\Modules\ClinicCore\Services\MedicalReportSyncService::class)->syncFromAppointment($appointment);
            } catch (\Throwable $e) {
                \Log::warning('ClinicCore lab→report sync failed: ' . $e->getMessage());
            }
        }

        // Auto-schedule next visit if a date was set and we don't already have one linked
        $autoNext = null;
        if ($appointment->next_visit_date && !$appointment->next_appointment_id) {
            $autoNext = $this->createNextAppointmentFrom($appointment);
        }

        // Detect any controlled medicines in the treatment plan that haven't
        // been logged yet — the client will open a "log dispense" modal so
        // every controlled item gets a register row with batch + witness.
        $pendingControlled = $this->pendingControlledDispenses($appointment);

        return response()->json([
            'success'             => true,
            'msg'                 => __('clinicCore::lang.updated_successfully'),
            'next_appointment_id' => $autoNext ? $autoNext->id : ($appointment->next_appointment_id ?? null),
            'pending_controlled'  => $pendingControlled,
        ]);
    }

    /**
     * SIFA P3-T4 — mirror the submitted consultation vitals into the
     * clc_vitals trend table so the Wave-B trend view stays fed from the single
     * (legacy) vitals-entry surface. Mirrors the fields ClcVitalsController::store
     * writes and reuses the ClcVital model so bmi + gcs_total auto-derive.
     *
     * Idempotent per appointment: updates the latest clc_vitals row for this
     * appointment when one exists, otherwise inserts a new reading. No-ops when
     * the request carries no vitals values (nothing to trend). The clc_vitals
     * table is optional — fail-soft when it isn't installed.
     */
    private function syncVitalsTrend(Request $request, ClcAppointment $appointment, $businessId): void
    {
        if (!Schema::hasTable('clc_vitals')) {
            return;
        }

        // Empty string / null → null. The legacy grid posts systolic_bp /
        // diastolic_bp; clc_vitals stores them as bp_systolic / bp_diastolic.
        $num = fn ($key) => ($request->input($key) === '' || $request->input($key) === null)
            ? null : $request->input($key);

        $values = [
            'temperature'      => $num('temperature'),
            'heart_rate'       => $num('heart_rate'),
            'respiratory_rate' => $num('respiratory_rate'),
            'bp_systolic'      => $num('systolic_bp'),
            'bp_diastolic'     => $num('diastolic_bp'),
            'spo2'             => $num('spo2'),
            'weight_kg'        => $num('weight_kg'),
            'blood_glucose'    => $num('blood_glucose'),
        ];

        // Nothing entered → don't create/refresh a blank trend row.
        if (empty(array_filter($values, fn ($v) => $v !== null))) {
            return;
        }

        // Idempotent: reuse the latest clc_vitals row already tied to this
        // appointment, else start a fresh reading.
        $vital = ClcVital::where('business_id', $businessId)
            ->where('appointment_id', $appointment->id)
            ->orderByDesc('id')
            ->first()
            ?: new ClcVital([
                'business_id'    => $businessId,
                'patient_id'     => $appointment->patient_id,
                'appointment_id' => $appointment->id,
            ]);

        $vital->recorded_by = auth()->id();
        $vital->recorded_at = now();
        foreach ($values as $col => $val) {
            $vital->{$col} = $val;
        }
        $vital->save(); // bmi + gcs_total auto-derived in the ClcVital model
    }

    /**
     * Find controlled medicines added to the appointment's treatment plan
     * that don't yet have a matching dispense row in the controlled register.
     */
    private function pendingControlledDispenses(ClcAppointment $appointment): array
    {
        $plan = json_decode($appointment->soap_plan_meds_json ?? '[]', true);
        if (!is_array($plan) || empty($plan)) return [];

        // Plan entries store the medicine id under "id" (catalogue picker)
        // — accept either "id" or "medicine_id" defensively.
        $medIds = array_values(array_filter(array_unique(array_map(
            fn($m) => (int) ($m['id'] ?? $m['medicine_id'] ?? 0), $plan
        ))));
        if (empty($medIds)) return [];

        $controlled = \Modules\ClinicCore\Entities\ClcMedicine::where('business_id', $appointment->business_id)
            ->controlled()
            ->whereIn('id', $medIds)
            ->get(['id', 'name', 'unit', 'controlled_schedule']);
        if ($controlled->isEmpty()) return [];

        $logged = \Modules\ClinicCore\Entities\ClcControlledMovement::where('appointment_id', $appointment->id)
            ->where('reason_code', 'dispense')
            ->pluck('medicine_id')->all();

        $schedules = \Modules\ClinicCore\Entities\ClcMedicine::schedules();
        $pending = [];
        foreach ($controlled as $med) {
            if (in_array($med->id, $logged)) continue;
            $row = collect($plan)->first(fn($e) => (int) ($e['id'] ?? $e['medicine_id'] ?? 0) === $med->id) ?: [];
            $pending[] = [
                'medicine_id'    => $med->id,
                'medicine_name'  => $med->name,
                'unit'           => $med->unit,
                'schedule'       => $med->controlled_schedule,
                'schedule_label' => $schedules[$med->controlled_schedule]['label'] ?? $med->controlled_schedule,
                'schedule_color' => $schedules[$med->controlled_schedule]['color']  ?? 'default',
                'dosage'         => $row['dosage'] ?? '',
                'freq'           => $row['freq']   ?? '',
                'days'           => $row['days']   ?? '',
            ];
        }
        return $pending;
    }

    /**
     * Build the "next visit" appointment from a finished one.
     * Idempotent: skips if already linked.
     */
    private function createNextAppointmentFrom(ClcAppointment $source): ?ClcAppointment
    {
        if (!$source->next_visit_date) return null;
        if ($source->next_appointment_id) return null;

        // Normalise time → HH:MM:SS
        $time = $source->next_visit_time ?: '09:00';
        if (strlen($time) === 5) $time .= ':00';

        $data = [
            'business_id'      => $source->business_id,
            'contact_id'       => $source->contact_id,
            'patient_id'           => $source->patient_id,
            'doctor_id'        => $source->next_visit_doctor_id ?: $source->doctor_id,
            'department_id'    => $source->next_visit_department_id ?: $source->department_id,
            'appointment_date' => $source->next_visit_date,
            'appointment_time' => $time,
            'duration_minutes' => $source->duration_minutes ?? 30,
            'type'             => $source->type ?? 'checkup',
            'reason'           => $source->next_visit_reason ?: 'Follow-up from ' . $source->ref_no,
            'notes'            => $source->next_visit_notes,
            'status'           => 'pending',
            'ref_no'           => 'APT-' . strtoupper(uniqid()),
        ];

        // Carry forward multi-select type/reason ids when the columns exist
        $tbl = (new ClcAppointment)->getTable();
        if (Schema::hasColumn($tbl, 'type_ids')   && !empty($source->next_visit_type_ids)) {
            $data['type_ids']   = $source->next_visit_type_ids;
        }
        if (Schema::hasColumn($tbl, 'reason_ids') && !empty($source->next_visit_reason_ids)) {
            $data['reason_ids'] = $source->next_visit_reason_ids;
        }

        $next = ClcAppointment::create($data);
        $source->update(['next_appointment_id' => $next->id]);
        $this->drmLog('create', $next, ['note' => 'Auto-scheduled from appointment #' . $source->id]);

        return $next;
    }

    public function print($id)
    {
        if (!auth()->user()->can('clinic.view_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId  = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::with([
            'pet', 'owner', 'doctor', 'department',
        ])->where('business_id', $businessId)->findOrFail($id);

        try { $appointment->load('location'); }                 catch (\Throwable $e) {}
        try { $appointment->load('pet.conditions.condition'); } catch (\Throwable $e) {}
        try { $appointment->load('appointmentNotes.user'); }     catch (\Throwable $e) {}
        try { $appointment->load('vaccinations.vaccineType'); }  catch (\Throwable $e) {}
        try { $appointment->load('prescriptions.items'); }       catch (\Throwable $e) {}

        foreach (['location', 'appointmentNotes', 'vaccinations', 'prescriptions'] as $rel) {
            if (!$appointment->relationLoaded($rel)) {
                $appointment->setRelation($rel, $rel === 'location' ? null : collect());
            }
        }
        if ($appointment->pet && !$appointment->pet->relationLoaded('conditions')) {
            $appointment->pet->setRelation('conditions', collect());
        }

        $business = \App\Business::findOrFail($businessId);

        return view('clinicCore::appointments.print', compact('appointment', 'business'));
    }

    /** AJAX: search ERP sell transactions by invoice number */
    public function searchInvoices(Request $request)
    {
        if (!auth()->user()->can('clinic.view_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId = request()->session()->get('user.business_id');
        $term       = trim($request->q ?? '');

        if (strlen($term) < 1) {
            return response()->json([]);
        }

        $results = \App\Transaction::join('contacts', 'transactions.contact_id', '=', 'contacts.id')
            ->where('transactions.business_id', $businessId)
            ->where('transactions.type', 'sell')
            ->where('transactions.invoice_no', 'like', '%' . $term . '%')
            ->select(
                'transactions.id',
                'transactions.invoice_no',
                'transactions.final_total',
                'transactions.transaction_date',
                'transactions.payment_status',
                'contacts.name as contact_name',
                'contacts.mobile as contact_mobile'
            )
            ->orderByDesc('transactions.transaction_date')
            ->limit(15)
            ->get()
            ->map(function ($t) {
                return [
                    'id'             => $t->id,
                    'invoice_no'     => $t->invoice_no,
                    'final_total'    => number_format($t->final_total, 2),
                    'transaction_date' => \Carbon\Carbon::parse($t->transaction_date)->format('d M Y'),
                    'payment_status' => ucfirst($t->payment_status ?? ''),
                    'contact_name'   => $t->contact_name ?? '—',
                    'contact_mobile' => $t->contact_mobile ?? '',
                ];
            });

        return response()->json($results);
    }

    /** Redirect to POS with pet owner + invoice line items pre-selected via session */
    public function launchPos($id)
    {
        if (!auth()->user()->can('clinic.view_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $businessId  = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::with(['owner', 'doctor', 'department', 'vaccinations.vaccineType', 'pet'])
            ->where('business_id', $businessId)->findOrFail($id);

        $owner = $appointment->owner;
        if ($owner) {
            session(['clc_preselect_contact' => [
                'id'               => $owner->id,
                'name'             => $owner->name ?? $owner->supplier_business_name ?? 'Contact',
                'balance'          => 0,
                'shipping_address' => '',
            ]]);
        }

        // Resolve ERP business_location_id via:
        //   appointment.department → clc_clinic_locations.business_location_id
        // Fall back to the appointment's own location_id mapping if present.
        $bizLocId = $this->resolveBusinessLocationId($appointment);
        if ($bizLocId) {
            session(['clc_preselect_business_location_id' => (int) $bizLocId]);
        } else {
            session()->forget('clc_preselect_business_location_id');
        }

        $items = $this->buildPosLineItems($appointment);
        if (!empty($items) || !empty($this->lastSkippedExams)) {
            session(['clc_preselect_products' => [
                'appointment_id' => $appointment->id,
                'pet_name'       => optional($appointment->pet)->patient_name,
                'ref_no'         => $appointment->ref_no,
                'items'          => $items,
                'skipped'        => $this->lastSkippedExams,
            ]]);
        } else {
            session()->forget('clc_preselect_products');
        }

        return redirect('/pos/create');
    }

    /**
     * Best-effort resolution of which ERP business_location to pre-select on POS:
     *   1. The vet department's clinic-location → its mapped business_location_id
     *   2. Else, the appointment's own location_id (if column exists) → mapped business_location_id
     */
    private function resolveBusinessLocationId(ClcAppointment $appointment): ?int
    {
        // 1. From department.location_id
        $dept = $appointment->department;
        if ($dept && !empty($dept->location_id)) {
            $loc = ClcClinicLocation::find($dept->location_id);
            if ($loc && !empty($loc->business_location_id)) {
                return (int) $loc->business_location_id;
            }
        }

        // 2. Fall back to appointment.location_id when set
        if (\Illuminate\Support\Facades\Schema::hasColumn('clc_appointments', 'location_id') && $appointment->location_id) {
            $loc = ClcClinicLocation::find($appointment->location_id);
            if ($loc && !empty($loc->business_location_id)) {
                return (int) $loc->business_location_id;
            }
        }

        return null;
    }

    /**
     * Assemble the list of products to pre-fill the POS with for this appointment:
     *   - Doctor's consultation fee (from doctor.product_id)
     *   - Every administered (non-waived) vaccination (from vaccineType.product_id)
     *   - Every "done" investigation/exam in exam_results JSON (from soap_option.product_id snapshot)
     * Each entry includes the primary variation_id required by POS's pos_product_row().
     */
    private function buildPosLineItems(ClcAppointment $appointment): array
    {
        $productIds = [];

        // Consultation — links to doctor's product but uses the doctor's own
        // consultation_fee as the unit-price override when it's > 0.
        if (optional($appointment->doctor)->product_id) {
            $docFee = (float) ($appointment->doctor->consultation_fee ?? 0);
            $productIds[$appointment->doctor->product_id] = [
                'source'         => 'visit',
                'qty'            => 1,
                'label'          => 'Consultation — ' . $appointment->doctor->name,
                'price_override' => $docFee > 0 ? $docFee : null,
            ];
        }

        // Vaccinations
        foreach ($appointment->vaccinations as $v) {
            if (($v->status ?? '') !== 'administered' || ($v->waived_at ?? null)) continue;
            $pid = optional($v->vaccineType)->product_id;
            if (!$pid) continue;
            // Multiple of the same vaccine bumps qty
            if (isset($productIds[$pid])) {
                $productIds[$pid]['qty'] += 1;
            } else {
                $productIds[$pid] = ['source' => 'vaccine', 'qty' => 1, 'label' => 'Vaccine — ' . optional($v->vaccineType)->name];
            }
        }

        // ── Exams / labs ──────────────────────────────────────────
        // Default behavior: every selected exam/lab bills UNLESS the user
        // explicitly marked it as 'not_done'. (Pending counts as billable.)
        $exams        = is_array($appointment->exam_results) ? $appointment->exam_results : [];
        $skippedExams = []; // surfaced to the front-end as a warning

        // Fallback when the JSON is empty but the legacy textareas have content
        if (empty($exams)) {
            foreach (['lab' => 'soap_lab_investigations', 'exam' => 'soap_exam_investigations'] as $kind => $col) {
                $text = (string) ($appointment->{$col} ?? '');
                if ($text === '') continue;
                foreach (preg_split('/\R/', $text) as $line) {
                    $label = trim(ltrim($line, "•\t -"));
                    if ($label === '') continue;
                    $exams[] = ['label' => $label, 'kind' => $kind, 'status' => 'pending', 'product_id' => null, 'soap_option_id' => null];
                }
            }
        }

        foreach ($exams as $x) {
            // Skip only when explicitly not_done
            if (($x['status'] ?? 'pending') === 'not_done') continue;

            $pid = $x['product_id'] ?? null;
            // Lookup via soap_option_id if no product cached on the JSON
            if (!$pid && !empty($x['soap_option_id'])) {
                $pid = \DB::table('clc_soap_options')->where('id', $x['soap_option_id'])->value('product_id');
            }
            // Last resort: match by section + label (look at business + system rows)
            if (!$pid && !empty($x['label'])) {
                $section = ($x['kind'] ?? '') === 'lab' ? 'lab_test' : 'exam_test';
                $pid = \DB::table('clc_soap_options')
                    ->where('section', $section)
                    ->where('label', $x['label'])
                    ->where(function ($q) use ($appointment) {
                        $q->where('business_id', $appointment->business_id)
                          ->orWhereNull('business_id');
                    })
                    ->whereNotNull('product_id')
                    ->orderByRaw('business_id IS NULL') // business-owned first
                    ->value('product_id');
            }
            if (!$pid) {
                $skippedExams[] = (($x['kind'] ?? 'exam') === 'lab' ? 'Lab' : 'Exam') . ' — ' . ($x['label'] ?? '?');
                continue;
            }
            if (isset($productIds[$pid])) {
                $productIds[$pid]['qty'] += 1;
            } else {
                $kindLabel = ($x['kind'] ?? '') === 'lab' ? 'Lab' : 'Exam';
                $productIds[$pid] = ['source' => 'exam', 'qty' => 1, 'label' => $kindLabel . ' — ' . ($x['label'] ?? '')];
            }
        }
        // Stash on $this so launchPos can read it before returning
        $this->lastSkippedExams = $skippedExams;

        if (empty($productIds)) return [];

        // Resolve primary variation_id + sell price in one query
        $variations = \DB::table('variations')
            ->whereIn('product_id', array_keys($productIds))
            ->whereNull('deleted_at')
            ->orderBy('id')
            ->get(['id', 'product_id', 'default_sell_price'])
            ->keyBy('product_id');

        $items = [];
        foreach ($productIds as $pid => $meta) {
            $v = $variations->get($pid);
            if (!$v) continue; // skip products without a variation
            $items[] = [
                'product_id'     => (int) $pid,
                'variation_id'   => (int) $v->id,
                'qty'            => (int) $meta['qty'],
                'label'          => $meta['label'],
                'source'         => $meta['source'],
                'unit_price'     => (float) $v->default_sell_price,
                // Per-doctor / per-item price override applied client-side after POS adds the row
                'price_override' => isset($meta['price_override']) ? $meta['price_override'] : null,
            ];
        }

        return $items;
    }

    /** Save erp_sell_id link between an ERP sell and this appointment */
    public function linkInvoice(Request $request, $id)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $request->validate(['erp_sell_id' => 'required|integer|min:1']);

        $businessId  = request()->session()->get('user.business_id');
        $appointment = ClcAppointment::where('business_id', $businessId)->findOrFail($id);
        $appointment->update(['erp_sell_id' => $request->erp_sell_id]);

        return response()->json(['success' => true, 'msg' => 'Invoice linked successfully.']);
    }

    /** Return DB-driven "not done" reasons for the consultation exam status panel */
    public function examNotDoneReasons()
    {
        $businessId = request()->session()->get('user.business_id');
        $reasons = ClcExamNotDoneReason::forBusiness($businessId)->active()->orderBy('sort_order')->orderBy('name')->get(['id', 'name']);
        return response()->json($reasons);
    }

    public function storeNote(Request $request, $id)
    {
        if (!auth()->user()->can('clinic.manage_appointments')) {
            abort(403, 'Unauthorized action.');
        }

        $request->validate(['notes' => 'required|string']);
        $businessId = request()->session()->get('user.business_id');
        ClcAppointment::where('business_id', $businessId)->findOrFail($id);

        $note = ClcAppointmentNote::create([
            'appointment_id' => $id,
            'user_id'        => auth()->id(),
            'title'          => $request->title,
            'notes'          => $request->notes,
        ]);

        $user = auth()->user();
        $dateFormat = session('business.date_format', 'd M Y');
        $timeFormat = session('business.time_format', 24) == 12 ? 'h:i A' : 'H:i';
        return response()->json([
            'success' => true,
            'msg'     => __('clinicCore::lang.saved_successfully'),
            'note'    => [
                'title'      => $note->title,
                'notes'      => $note->notes,
                'user_name'  => trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')),
                'created_at' => $note->created_at->format($dateFormat . ' ' . $timeFormat),
            ],
        ]);
    }
}
