<?php

namespace Modules\MedWorkforce\Services;

use Carbon\CarbonPeriod;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\MedWorkforce\Entities\RosterAssignment;
use Modules\MedWorkforce\Entities\ShiftTemplate;
use Modules\MedWorkforce\Entities\SwapRequest;

/**
 * Duty-roster domain service (SIFA P7-T1 shift_roster).
 *
 * EXTENDS Essentials HR — it does NOT rebuild it. The roster is a medical
 * overlay whose staff (user_id) are the SAME ERP `users` Essentials attendance /
 * leave track. When Essentials is installed, assign()/bulkAssign() OPTIONALLY
 * mirror the shift template into essentials_shifts + essentials_user_shifts so
 * the Essentials attendance/auto-clockout engine keeps recognising the shift —
 * a fail-soft bind (class_exists / Schema::hasTable guards), never a hard
 * dependency.
 *
 * All writes are business-scoped and idempotent where natural keys allow.
 */
class RosterService
{
    /**
     * Assign one staff-member to a shift on a date. Returns the assignment plus a
     * non-blocking `warnings` array (double-booking, min-rest) so the UI can warn
     * without refusing the save.
     *
     * @param  array{shift_template_id?:int|null,department_id?:int|null,business_location_id?:int|null,status?:string,notes?:string|null,created_by?:int|null}  $attrs
     * @return array{assignment: RosterAssignment, warnings: string[]}
     */
    public function assign(int $businessId, int $userId, string $date, array $attrs = []): array
    {
        $templateId = ! empty($attrs['shift_template_id']) ? (int) $attrs['shift_template_id'] : null;

        $warnings = $this->detectConflicts($businessId, $userId, $date, $templateId);

        // Idempotent per (business, user, date, template) so re-painting a cell
        // updates rather than duplicates.
        $assignment = RosterAssignment::updateOrCreate(
            [
                'business_id'       => $businessId,
                'user_id'           => $userId,
                'date'              => $date,
                'shift_template_id' => $templateId,
            ],
            [
                'department_id'        => $attrs['department_id'] ?? null,
                'business_location_id' => $attrs['business_location_id'] ?? null,
                'status'               => $attrs['status'] ?? 'scheduled',
                'notes'                => $attrs['notes'] ?? null,
                'created_by'           => $attrs['created_by'] ?? null,
            ]
        );

        $this->mirrorToEssentials($businessId, $userId, $date, $templateId);

        return ['assignment' => $assignment, 'warnings' => $warnings];
    }

    /**
     * Assign a template to many staff across a date range (the "bulk assign"
     * modal). Skips weekends only if $skipDays lists them. Returns a summary with
     * an aggregated, de-duplicated warnings list.
     *
     * @param  int[]     $userIds
     * @param  int[]     $skipDows  ISO day-of-week numbers (1=Mon..7=Sun) to skip
     * @return array{created:int, warnings: string[]}
     */
    public function bulkAssign(
        int $businessId,
        int $templateId,
        array $userIds,
        string $from,
        string $to,
        array $attrs = [],
        array $skipDows = []
    ): array {
        $template = ShiftTemplate::forBusiness($businessId)->find($templateId);
        if (! $template) {
            return ['created' => 0, 'warnings' => ['template_not_found']];
        }

        $period = CarbonPeriod::create($from, $to);
        $created = 0;
        $warnings = [];

        foreach ($period as $day) {
            if (in_array((int) $day->isoWeekday(), $skipDows, true)) {
                continue;
            }
            $dateStr = $day->toDateString();

            foreach ($userIds as $userId) {
                $out = $this->assign($businessId, (int) $userId, $dateStr, [
                    'shift_template_id'    => $templateId,
                    'department_id'        => $attrs['department_id'] ?? $template->department_id,
                    'business_location_id' => $attrs['business_location_id'] ?? $template->business_location_id,
                    'status'               => $attrs['status'] ?? 'scheduled',
                    'created_by'           => $attrs['created_by'] ?? null,
                ]);
                $created++;
                foreach ($out['warnings'] as $w) {
                    $warnings[$w] = true;
                }
            }
        }

        return ['created' => $created, 'warnings' => array_keys($warnings)];
    }

    /**
     * Raise a swap request against an assignment. The requester is the current
     * assignment holder; requested_with is the staff being asked to take it.
     */
    public function requestSwap(int $businessId, int $assignmentId, int $requestedBy, int $requestedWithUserId, ?string $reason = null): SwapRequest
    {
        $assignment = RosterAssignment::forBusiness($businessId)->findOrFail($assignmentId);

        return SwapRequest::create([
            'business_id'            => $businessId,
            'roster_assignment_id'   => $assignment->id,
            'requested_by'           => $requestedBy,
            'requested_with_user_id' => $requestedWithUserId,
            'reason'                 => $reason,
            'status'                 => 'pending',
        ]);
    }

    /**
     * Approve a swap: move the roster assignment to the requested-with user and
     * mark it `swapped` (recording the previous holder in swap_with_user_id).
     * Wrapped in a transaction; the decision is recorded on the request.
     */
    public function approveSwap(int $businessId, int $swapId, int $decidedBy): SwapRequest
    {
        return DB::transaction(function () use ($businessId, $swapId, $decidedBy) {
            $swap = SwapRequest::forBusiness($businessId)->where('status', 'pending')->findOrFail($swapId);
            $assignment = RosterAssignment::forBusiness($businessId)->findOrFail($swap->roster_assignment_id);

            $previousUserId = (int) $assignment->user_id;

            $assignment->update([
                'user_id'           => $swap->requested_with_user_id,
                'swap_with_user_id' => $previousUserId,
                'status'            => 'swapped',
            ]);

            $swap->update([
                'status'     => 'approved',
                'decided_by' => $decidedBy,
                'decided_at' => now(),
            ]);

            // Keep the Essentials mirror aligned with the new holder.
            $this->mirrorToEssentials(
                $businessId,
                (int) $swap->requested_with_user_id,
                $assignment->date instanceof Carbon ? $assignment->date->toDateString() : (string) $assignment->date,
                $assignment->shift_template_id ? (int) $assignment->shift_template_id : null
            );

            return $swap;
        });
    }

    /** Reject a pending swap request. */
    public function rejectSwap(int $businessId, int $swapId, int $decidedBy): SwapRequest
    {
        $swap = SwapRequest::forBusiness($businessId)->where('status', 'pending')->findOrFail($swapId);
        $swap->update([
            'status'     => 'rejected',
            'decided_by' => $decidedBy,
            'decided_at' => now(),
        ]);

        return $swap;
    }

    /**
     * Non-blocking conflict detection for a proposed assignment:
     *   - double-booking: the same user already has an active assignment that
     *     day (a different template);
     *   - min-rest: the previous/next day's shift leaves less than the
     *     configured rest window (only evaluated when both shifts have a template
     *     with times).
     *
     * @return string[] warning keys the caller maps to lang strings
     */
    public function detectConflicts(int $businessId, int $userId, string $date, ?int $templateId): array
    {
        $warnings = [];

        $sameDay = RosterAssignment::forBusiness($businessId)
            ->active()
            ->where('user_id', $userId)
            ->whereDate('date', $date)
            ->when($templateId, fn ($q) => $q->where('shift_template_id', '!=', $templateId))
            ->exists();

        if ($sameDay) {
            $warnings[] = 'double_booked';
        }

        if ($this->violatesMinRest($businessId, $userId, $date, $templateId)) {
            $warnings[] = 'min_rest';
        }

        return $warnings;
    }

    /**
     * OPTIONAL min-rest check: does the proposed shift leave less than
     * config('medworkforce.min_rest_hours') between it and the adjacent day's
     * shift? Purely advisory — returns false when there is not enough data.
     */
    public function violatesMinRest(int $businessId, int $userId, string $date, ?int $templateId): bool
    {
        if (! $templateId) {
            return false;
        }

        $template = ShiftTemplate::forBusiness($businessId)->find($templateId);
        if (! $template || ! $template->start_time || ! $template->end_time) {
            return false;
        }

        $minRest = (float) config('medworkforce.min_rest_hours', 11);
        $day = Carbon::parse($date);

        // Look at the immediately-preceding and following day's assignments.
        $neighbours = RosterAssignment::forBusiness($businessId)
            ->active()
            ->where('user_id', $userId)
            ->whereIn('date', [$day->copy()->subDay()->toDateString(), $day->copy()->addDay()->toDateString()])
            ->whereNotNull('shift_template_id')
            ->with('template')
            ->get();

        foreach ($neighbours as $n) {
            if (! $n->template || ! $n->template->start_time || ! $n->template->end_time) {
                continue;
            }

            $isPrev = Carbon::parse($n->date)->lt($day);

            if ($isPrev) {
                // Previous day's end -> this day's start.
                $prevEnd = Carbon::parse($n->date->toDateString() . ' ' . $n->template->end_time);
                if (Carbon::parse($n->template->end_time)->lt(Carbon::parse($n->template->start_time))) {
                    $prevEnd->addDay(); // overnight shift ends next morning
                }
                $thisStart = Carbon::parse($day->toDateString() . ' ' . $template->start_time);
                if ($prevEnd->diffInMinutes($thisStart, false) < $minRest * 60) {
                    return true;
                }
            } else {
                // This day's end -> next day's start.
                $thisEnd = Carbon::parse($day->toDateString() . ' ' . $template->end_time);
                if (Carbon::parse($template->end_time)->lt(Carbon::parse($template->start_time))) {
                    $thisEnd->addDay();
                }
                $nextStart = Carbon::parse(Carbon::parse($n->date)->toDateString() . ' ' . $n->template->start_time);
                if ($thisEnd->diffInMinutes($nextStart, false) < $minRest * 60) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * FAIL-SOFT bind to Essentials: mirror the assigned template into
     * essentials_shifts (matched by name) + essentials_user_shifts so the
     * Essentials attendance engine recognises the roster shift. A clean no-op
     * when Essentials is not installed or the template has no name/times.
     */
    protected function mirrorToEssentials(int $businessId, int $userId, string $date, ?int $templateId): void
    {
        if (! $templateId) {
            return;
        }
        if (! Schema::hasTable('essentials_shifts') || ! Schema::hasTable('essentials_user_shifts')) {
            return; // Essentials HR not installed — roster still works standalone.
        }

        try {
            $template = ShiftTemplate::forBusiness($businessId)->find($templateId);
            if (! $template || empty($template->name)) {
                return;
            }

            // Reuse an existing Essentials shift with the same name for this business,
            // else create one (fixed_shift with the template's times).
            $shift = DB::table('essentials_shifts')
                ->where('business_id', $businessId)
                ->where('name', $template->name)
                ->first();

            if (! $shift) {
                $shiftId = DB::table('essentials_shifts')->insertGetId([
                    'name'        => $template->name,
                    'type'        => 'fixed_shift',
                    'business_id' => $businessId,
                    'start_time'  => $template->start_time,
                    'end_time'    => $template->end_time,
                    'created_at'  => now(),
                    'updated_at'  => now(),
                ]);
            } else {
                $shiftId = (int) $shift->id;
            }

            // essentials_user_shifts has no business_id; scope through the shift.
            $exists = DB::table('essentials_user_shifts')
                ->where('user_id', $userId)
                ->where('essentials_shift_id', $shiftId)
                ->where(function ($q) use ($date) {
                    $q->whereNull('start_date')->orWhere('start_date', '<=', $date);
                })
                ->where(function ($q) use ($date) {
                    $q->whereNull('end_date')->orWhere('end_date', '>=', $date);
                })
                ->exists();

            if (! $exists) {
                DB::table('essentials_user_shifts')->insert([
                    'user_id'             => $userId,
                    'essentials_shift_id' => $shiftId,
                    'start_date'          => $date,
                    'end_date'            => $date,
                    'created_at'          => now(),
                    'updated_at'          => now(),
                ]);
            }
        } catch (\Throwable $e) {
            // Never let the Essentials mirror break a roster save.
            \Log::warning('MedWorkforce: could not mirror roster to Essentials: ' . $e->getMessage());
        }
    }
}
