Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 443
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventService
0.00% covered (danger)
0.00%
0 / 443
0.00% covered (danger)
0.00%
0 / 24
9506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setIntegrationService
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getIntegrationService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 list
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 get
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getIntegrations
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 create
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
20
 update
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
210
 delete
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
56
 activate
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
42
 cancel
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 archive
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 unarchive
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 permanentDelete
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 duplicate
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
42
 checkConflicts
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 logAudit
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 validateTransition
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 recalculatePhase
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 hydrateEventFromData
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 mergeEventData
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 parseDateTime
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 camelToSnake
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatValueForDb
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace BuyerKiosk\EventManagement\Services;
4
5use PDO;
6use PDOException;
7use DateTime;
8use InvalidArgumentException;
9use RuntimeException;
10use BuyerKiosk\EventManagement\Models\Event;
11use BuyerKiosk\EventManagement\Models\EventIntegration;
12use BuyerKiosk\EventManagement\Services\IntegrationService;
13
14/**
15 * EventService - Manages store event lifecycle and CRUD operations
16 *
17 * Handles all event-related operations including:
18 * - CRUD operations for events
19 * - Status lifecycle transitions (draft -> scheduled -> active -> completed)
20 * - Event phase calculation
21 * - Conflict detection for overlapping events
22 * - Audit logging for all mutations
23 *
24 * @package BuyerKiosk\EventManagement\Services
25 */
26class EventService
27{
28    private PDO $db;
29    private ?int $employeeId;
30    private ?IntegrationService $integrationService = null;
31
32    /**
33     * Valid status transitions
34     *
35     * Key: current status, Value: array of allowed target statuses
36     */
37    private const STATUS_TRANSITIONS = [
38        Event::STATUS_DRAFT => [Event::STATUS_SCHEDULED, Event::STATUS_CANCELLED],
39        Event::STATUS_SCHEDULED => [Event::STATUS_ACTIVE, Event::STATUS_CANCELLED],
40        Event::STATUS_ACTIVE => [Event::STATUS_COMPLETED, Event::STATUS_CANCELLED],
41        Event::STATUS_COMPLETED => [Event::STATUS_ARCHIVED],
42        Event::STATUS_CANCELLED => [Event::STATUS_ARCHIVED],
43        Event::STATUS_ARCHIVED => [], // Can only unarchive via special method
44    ];
45
46    /**
47     * Constructor
48     *
49     * @param PDO $db Database connection for the store
50     * @param int|null $employeeId Employee ID for audit logging
51     */
52    public function __construct(PDO $db, ?int $employeeId = null)
53    {
54        $this->db = $db;
55        $this->employeeId = $employeeId;
56    }
57
58    /**
59     * Set the IntegrationService for cascade operations
60     *
61     * When set, cascade operations (date sync, activate, deactivate, delete)
62     * will use the IntegrationService to cascade changes to all linked systems.
63     *
64     * @param IntegrationService $integrationService
65     * @return self For method chaining
66     */
67    public function setIntegrationService(IntegrationService $integrationService): self
68    {
69        $this->integrationService = $integrationService;
70        return $this;
71    }
72
73    /**
74     * Get the IntegrationService
75     *
76     * @return IntegrationService|null
77     */
78    public function getIntegrationService(): ?IntegrationService
79    {
80        return $this->integrationService;
81    }
82
83    // =========================================================================
84    // LIST & GET OPERATIONS
85    // =========================================================================
86
87    /**
88     * List events with filtering
89     *
90     * @param array $filters ['year' => int, 'status' => string, 'eventType' => string, 'includeArchived' => bool]
91     * @return Event[] Array of Event objects
92     */
93    public function list(array $filters = []): array
94    {
95        $sql = "SELECT * FROM events WHERE 1=1";
96        $params = [];
97
98        if (isset($filters['year'])) {
99            $sql .= " AND year = :year";
100            $params[':year'] = (int) $filters['year'];
101        }
102
103        if (isset($filters['status'])) {
104            $sql .= " AND status = :status";
105            $params[':status'] = $filters['status'];
106        }
107
108        if (isset($filters['eventType'])) {
109            $sql .= " AND eventType = :eventType";
110            $params[':eventType'] = $filters['eventType'];
111        }
112
113        if (!($filters['includeArchived'] ?? false)) {
114            $sql .= " AND status != 'archived'";
115        }
116
117        $sql .= " ORDER BY startDate DESC, created_at DESC";
118
119        $stmt = $this->db->prepare($sql);
120        $stmt->execute($params);
121
122        $events = [];
123        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
124            $events[] = Event::fromRow($row);
125        }
126
127        return $events;
128    }
129
130    /**
131     * Get single event by ID with integrations loaded
132     *
133     * @param int $eventId Event ID
134     * @return Event|null Event object or null if not found
135     */
136    public function get(int $eventId): ?Event
137    {
138        $sql = "SELECT * FROM events WHERE id = :id LIMIT 1";
139        $stmt = $this->db->prepare($sql);
140        $stmt->execute([':id' => $eventId]);
141        $row = $stmt->fetch(PDO::FETCH_ASSOC);
142
143        if (!$row) {
144            return null;
145        }
146
147        return Event::fromRow($row);
148    }
149
150    /**
151     * Get integrations for an event
152     *
153     * @param int $eventId Event ID
154     * @return EventIntegration[] Array of EventIntegration objects
155     */
156    public function getIntegrations(int $eventId): array
157    {
158        $sql = "SELECT * FROM event_integrations WHERE eventId = :eventId ORDER BY integrationType, id";
159        $stmt = $this->db->prepare($sql);
160        $stmt->execute([':eventId' => $eventId]);
161
162        $integrations = [];
163        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
164            $integrations[] = EventIntegration::fromRow($row);
165        }
166
167        return $integrations;
168    }
169
170    // =========================================================================
171    // CREATE, UPDATE, DELETE OPERATIONS
172    // =========================================================================
173
174    /**
175     * Create new event
176     *
177     * @param array $data Event data
178     * @return Event Created event
179     * @throws InvalidArgumentException On validation failure
180     */
181    public function create(array $data): Event
182    {
183        // Hydrate and validate the event
184        $event = $this->hydrateEventFromData($data);
185        $errors = $event->validate();
186
187        if (!empty($errors)) {
188            throw new InvalidArgumentException(implode('; ', $errors));
189        }
190
191        // Check for conflicts
192        $conflicts = $this->checkConflicts($data);
193        if ($conflicts['hasConflicts']) {
194            throw new InvalidArgumentException(
195                'Event conflicts detected: ' . implode('; ', $conflicts['conflicts'])
196            );
197        }
198
199        $sql = "INSERT INTO events (
200            templateId, sourceEventId, name, description, eventType, year,
201            startDate, endDate, buildUpDays, windDownDays,
202            status, previousStatus, phase, color, icon, isRecurring,
203            createdBy, created_at, updated_at
204        ) VALUES (
205            :templateId, :sourceEventId, :name, :description, :eventType, :year,
206            :startDate, :endDate, :buildUpDays, :windDownDays,
207            :status, :previousStatus, :phase, :color, :icon, :isRecurring,
208            :createdBy, NOW(), NOW()
209        )";
210
211        $stmt = $this->db->prepare($sql);
212        $stmt->execute([
213            ':templateId' => $event->templateId,
214            ':sourceEventId' => $event->sourceEventId,
215            ':name' => $event->name,
216            ':description' => $event->description,
217            ':eventType' => $event->eventType,
218            ':year' => $event->year,
219            ':startDate' => $event->startDate?->format('Y-m-d H:i:s'),
220            ':endDate' => $event->endDate?->format('Y-m-d H:i:s'),
221            ':buildUpDays' => $event->buildUpDays,
222            ':windDownDays' => $event->windDownDays,
223            ':status' => $event->status,
224            ':previousStatus' => $event->previousStatus,
225            ':phase' => $event->calculatePhase(),
226            ':color' => $event->color,
227            ':icon' => $event->icon,
228            ':isRecurring' => $event->isRecurring ? 1 : 0,
229            ':createdBy' => $this->employeeId ?? $event->createdBy,
230        ]);
231
232        $eventId = (int) $this->db->lastInsertId();
233        $createdEvent = $this->get($eventId);
234
235        // Log the creation
236        $this->logAudit($eventId, 'created', [
237            'name' => $event->name,
238            'eventType' => $event->eventType,
239            'status' => $event->status,
240        ]);
241
242        return $createdEvent;
243    }
244
245    /**
246     * Update existing event
247     *
248     * @param int $eventId Event ID
249     * @param array $data Fields to update
250     * @return Event Updated event
251     * @throws InvalidArgumentException On validation failure
252     */
253    public function update(int $eventId, array $data): Event
254    {
255        $existingEvent = $this->get($eventId);
256        if (!$existingEvent) {
257            throw new InvalidArgumentException('Event not found');
258        }
259
260        // Merge existing data with updates
261        $mergedData = $this->mergeEventData($existingEvent, $data);
262        $event = $this->hydrateEventFromData($mergedData);
263        $event->id = $eventId;
264
265        $errors = $event->validate();
266        if (!empty($errors)) {
267            throw new InvalidArgumentException(implode('; ', $errors));
268        }
269
270        // Check for conflicts (excluding this event)
271        $conflicts = $this->checkConflicts($mergedData, $eventId);
272        if ($conflicts['hasConflicts']) {
273            throw new InvalidArgumentException(
274                'Event conflicts detected: ' . implode('; ', $conflicts['conflicts'])
275            );
276        }
277
278        // Build dynamic UPDATE query
279        $allowedFields = [
280            'name', 'description', 'eventType', 'year',
281            'startDate', 'endDate', 'buildUpDays', 'windDownDays',
282            'color', 'icon', 'isRecurring',
283        ];
284
285        $setClauses = [];
286        $params = [':id' => $eventId];
287        $changes = [];
288
289        foreach ($allowedFields as $field) {
290            if (array_key_exists($field, $data)) {
291                // DB uses camelCase column names, so no conversion needed
292                $value = $this->formatValueForDb($field, $data[$field]);
293                $setClauses[] = "`{$field}` = :{$field}";
294                $params[":{$field}"] = $value;
295                $changes[$field] = $data[$field];
296            }
297        }
298
299        if (empty($setClauses)) {
300            return $existingEvent;
301        }
302
303        // Recalculate phase if dates changed
304        $datesChanged = isset($data['startDate']) || isset($data['endDate']) ||
305                        isset($data['buildUpDays']) || isset($data['windDownDays']);
306
307        if ($datesChanged) {
308            $setClauses[] = "`phase` = :phase";
309            $params[':phase'] = $event->calculatePhase();
310        }
311
312        $setClauses[] = "updated_at = NOW()";
313        $sql = "UPDATE events SET " . implode(', ', $setClauses) . " WHERE id = :id";
314
315        $stmt = $this->db->prepare($sql);
316        $stmt->execute($params);
317
318        // Log the update
319        $this->logAudit($eventId, 'updated', $changes);
320
321        // Get the updated event
322        $updatedEvent = $this->get($eventId);
323
324        // Cascade date changes to integrations if dates changed and service is available
325        if ($datesChanged && $this->integrationService !== null) {
326            $syncResult = $this->integrationService->syncAllDates($updatedEvent);
327            if (!empty($syncResult['failed'])) {
328                // Log failed syncs but don't fail the update
329                error_log("EventService: Some integrations failed to sync dates: " .
330                    json_encode($syncResult['failed']));
331            }
332        }
333
334        return $updatedEvent;
335    }
336
337    /**
338     * Delete event (cascade to integrations)
339     *
340     * @param int $eventId Event ID
341     * @return array ['success' => bool, 'cascadeDeleted' => ['type' => count], 'failed' => array]
342     * @throws InvalidArgumentException If event cannot be deleted
343     */
344    public function delete(int $eventId): array
345    {
346        $event = $this->get($eventId);
347        if (!$event) {
348            throw new InvalidArgumentException('Event not found');
349        }
350
351        // Only draft events can be deleted
352        if ($event->status !== Event::STATUS_DRAFT) {
353            throw new InvalidArgumentException('Only draft events can be deleted. Use archive for non-draft events.');
354        }
355
356        $this->db->beginTransaction();
357
358        try {
359            $cascadeResult = ['deleted' => 0, 'failed' => [], 'byType' => []];
360
361            // Use IntegrationService for cascade delete if available
362            if ($this->integrationService !== null) {
363                $cascadeResult = $this->integrationService->cascadeDelete($eventId);
364            } else {
365                // Fallback: just delete integration link records (target records remain)
366                $sql = "SELECT integrationType, COUNT(*) as cnt FROM event_integrations WHERE eventId = :eventId GROUP BY integrationType";
367                $stmt = $this->db->prepare($sql);
368                $stmt->execute([':eventId' => $eventId]);
369                while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
370                    $cascadeResult['byType'][$row['integrationType']] = (int) $row['cnt'];
371                }
372
373                $sql = "DELETE FROM event_integrations WHERE eventId = :eventId";
374                $stmt = $this->db->prepare($sql);
375                $stmt->execute([':eventId' => $eventId]);
376            }
377
378            // Delete the event
379            $sql = "DELETE FROM events WHERE id = :id AND status = 'draft'";
380            $stmt = $this->db->prepare($sql);
381            $stmt->execute([':id' => $eventId]);
382
383            if ($stmt->rowCount() === 0) {
384                throw new RuntimeException('Failed to delete event');
385            }
386
387            // Log the deletion
388            $this->logAudit($eventId, 'deleted', [
389                'name' => $event->name,
390                'cascadeDeleted' => $cascadeResult['byType'],
391            ]);
392
393            $this->db->commit();
394
395            return [
396                'success' => true,
397                'cascadeDeleted' => $cascadeResult['byType'],
398                'failed' => $cascadeResult['failed'],
399            ];
400        } catch (\Exception $e) {
401            $this->db->rollBack();
402            throw $e;
403        }
404    }
405
406    // =========================================================================
407    // LIFECYCLE TRANSITIONS
408    // =========================================================================
409
410    /**
411     * Activate event (change status to scheduled or active based on dates)
412     *
413     * @param int $eventId Event ID
414     * @return Event Updated event
415     * @throws InvalidArgumentException On invalid transition
416     */
417    public function activate(int $eventId): Event
418    {
419        $event = $this->get($eventId);
420        if (!$event) {
421            throw new InvalidArgumentException('Event not found');
422        }
423
424        $this->validateTransition($event, Event::STATUS_ACTIVE);
425
426        // Must have dates to activate
427        if ($event->startDate === null || $event->endDate === null) {
428            throw new InvalidArgumentException('Event must have start and end dates to be activated');
429        }
430
431        $this->db->beginTransaction();
432
433        try {
434            // Update event status
435            $sql = "UPDATE events SET
436                status = 'active',
437                previousStatus = :previousStatus,
438                phase = :phase,
439                updated_at = NOW()
440                WHERE id = :id";
441            $stmt = $this->db->prepare($sql);
442            $stmt->execute([
443                ':id' => $eventId,
444                ':previousStatus' => $event->status,
445                ':phase' => Event::PHASE_ACTIVE,
446            ]);
447
448            // Activate pending integrations using IntegrationService if available
449            $activationResult = ['activated' => 0, 'failed' => []];
450            if ($this->integrationService !== null) {
451                $updatedEvent = $this->get($eventId);
452                $activationResult = $this->integrationService->activatePending($updatedEvent);
453            } else {
454                // Fallback: just update integration status records
455                $sql = "UPDATE event_integrations SET status = 'active' WHERE event_id = :eventId AND status = 'pending'";
456                $stmt = $this->db->prepare($sql);
457                $stmt->execute([':eventId' => $eventId]);
458            }
459
460            $this->logAudit($eventId, 'activated', [
461                'previousStatus' => $event->status,
462                'newStatus' => Event::STATUS_ACTIVE,
463                'integrationsActivated' => $activationResult['activated'],
464                'integrationsFailed' => count($activationResult['failed']),
465            ]);
466
467            $this->db->commit();
468
469            return $this->get($eventId);
470        } catch (\Exception $e) {
471            $this->db->rollBack();
472            throw $e;
473        }
474    }
475
476    /**
477     * Cancel event and deactivate integrations
478     *
479     * @param int $eventId Event ID
480     * @return Event Updated event
481     * @throws InvalidArgumentException On invalid transition
482     */
483    public function cancel(int $eventId): Event
484    {
485        $event = $this->get($eventId);
486        if (!$event) {
487            throw new InvalidArgumentException('Event not found');
488        }
489
490        $this->validateTransition($event, Event::STATUS_CANCELLED);
491
492        $this->db->beginTransaction();
493
494        try {
495            // Update event status
496            $sql = "UPDATE events SET
497                status = 'cancelled',
498                previousStatus = :previousStatus,
499                updated_at = NOW()
500                WHERE id = :id";
501            $stmt = $this->db->prepare($sql);
502            $stmt->execute([
503                ':id' => $eventId,
504                ':previousStatus' => $event->status,
505            ]);
506
507            // Deactivate integrations using IntegrationService if available
508            $deactivationResult = ['deactivated' => 0, 'failed' => []];
509            if ($this->integrationService !== null) {
510                $deactivationResult = $this->integrationService->deactivateAll($event);
511            } else {
512                // Fallback: just update integration status records
513                $sql = "UPDATE event_integrations SET status = 'failed'
514                        WHERE event_id = :eventId AND status IN ('pending', 'active')";
515                $stmt = $this->db->prepare($sql);
516                $stmt->execute([':eventId' => $eventId]);
517                $deactivationResult['deactivated'] = $stmt->rowCount();
518            }
519
520            $this->logAudit($eventId, 'cancelled', [
521                'previousStatus' => $event->status,
522                'deactivatedIntegrations' => $deactivationResult['deactivated'],
523                'deactivationFailed' => count($deactivationResult['failed']),
524            ]);
525
526            $this->db->commit();
527
528            return $this->get($eventId);
529        } catch (\Exception $e) {
530            $this->db->rollBack();
531            throw $e;
532        }
533    }
534
535    /**
536     * Archive event (soft delete)
537     *
538     * @param int $eventId Event ID
539     * @return Event Updated event
540     * @throws InvalidArgumentException On invalid transition
541     */
542    public function archive(int $eventId): Event
543    {
544        $event = $this->get($eventId);
545        if (!$event) {
546            throw new InvalidArgumentException('Event not found');
547        }
548
549        $this->validateTransition($event, Event::STATUS_ARCHIVED);
550
551        $sql = "UPDATE events SET
552            status = 'archived',
553            previousStatus = :previousStatus,
554            archivedAt = NOW(),
555            updated_at = NOW()
556            WHERE id = :id";
557        $stmt = $this->db->prepare($sql);
558        $stmt->execute([
559            ':id' => $eventId,
560            ':previousStatus' => $event->status,
561        ]);
562
563        $this->logAudit($eventId, 'archived', [
564            'previousStatus' => $event->status,
565        ]);
566
567        return $this->get($eventId);
568    }
569
570    /**
571     * Unarchive event (restore previous status)
572     *
573     * @param int $eventId Event ID
574     * @return Event Updated event
575     * @throws InvalidArgumentException If event is not archived
576     */
577    public function unarchive(int $eventId): Event
578    {
579        $event = $this->get($eventId);
580        if (!$event) {
581            throw new InvalidArgumentException('Event not found');
582        }
583
584        if ($event->status !== Event::STATUS_ARCHIVED) {
585            throw new InvalidArgumentException('Only archived events can be unarchived');
586        }
587
588        // Restore to previous status, default to draft if no previous status
589        $restoreStatus = $event->previousStatus ?? Event::STATUS_DRAFT;
590
591        // If restoring to active, verify it's still valid
592        if ($restoreStatus === Event::STATUS_ACTIVE) {
593            $now = new DateTime();
594            if ($event->endDate !== null && $event->endDate < $now) {
595                $restoreStatus = Event::STATUS_COMPLETED;
596            }
597        }
598
599        $sql = "UPDATE events SET
600            status = :status,
601            previousStatus = 'archived',
602            archivedAt = NULL,
603            updated_at = NOW()
604            WHERE id = :id";
605        $stmt = $this->db->prepare($sql);
606        $stmt->execute([
607            ':id' => $eventId,
608            ':status' => $restoreStatus,
609        ]);
610
611        $this->logAudit($eventId, 'unarchived', [
612            'restoredStatus' => $restoreStatus,
613        ]);
614
615        return $this->get($eventId);
616    }
617
618    /**
619     * Permanently delete archived event
620     *
621     * @param int $eventId Event ID
622     * @return array ['success' => bool, 'cascadeDeleted' => ['type' => count]]
623     * @throws InvalidArgumentException If event is not archived
624     */
625    public function permanentDelete(int $eventId): array
626    {
627        $event = $this->get($eventId);
628        if (!$event) {
629            throw new InvalidArgumentException('Event not found');
630        }
631
632        if ($event->status !== Event::STATUS_ARCHIVED) {
633            throw new InvalidArgumentException('Only archived events can be permanently deleted');
634        }
635
636        $this->db->beginTransaction();
637
638        try {
639            // Get integration counts by type for return value
640            $integrationCounts = [];
641            $sql = "SELECT integrationType, COUNT(*) as cnt FROM event_integrations WHERE eventId = :eventId GROUP BY integrationType";
642            $stmt = $this->db->prepare($sql);
643            $stmt->execute([':eventId' => $eventId]);
644            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
645                $integrationCounts[$row['integrationType']] = (int) $row['cnt'];
646            }
647
648            // Delete audit logs
649            $sql = "DELETE FROM event_audit_log WHERE eventId = :eventId";
650            $stmt = $this->db->prepare($sql);
651            $stmt->execute([':eventId' => $eventId]);
652
653            // Delete integrations
654            $sql = "DELETE FROM event_integrations WHERE eventId = :eventId";
655            $stmt = $this->db->prepare($sql);
656            $stmt->execute([':eventId' => $eventId]);
657
658            // Delete the event
659            $sql = "DELETE FROM events WHERE id = :id AND status = 'archived'";
660            $stmt = $this->db->prepare($sql);
661            $stmt->execute([':id' => $eventId]);
662
663            if ($stmt->rowCount() === 0) {
664                throw new RuntimeException('Failed to permanently delete event');
665            }
666
667            $this->db->commit();
668
669            return [
670                'success' => true,
671                'cascadeDeleted' => $integrationCounts,
672            ];
673        } catch (\Exception $e) {
674            $this->db->rollBack();
675            throw $e;
676        }
677    }
678
679    // =========================================================================
680    // DUPLICATE OPERATION
681    // =========================================================================
682
683    /**
684     * Duplicate event with new dates
685     *
686     * @param int $eventId Source event ID
687     * @param array $data ['name' => string, 'startDate' => string, 'endDate' => string, 'includeIntegrations' => bool]
688     * @return Event Duplicated event
689     * @throws InvalidArgumentException If source event not found
690     */
691    public function duplicate(int $eventId, array $data): Event
692    {
693        $sourceEvent = $this->get($eventId);
694        if (!$sourceEvent) {
695            throw new InvalidArgumentException('Source event not found');
696        }
697
698        // Prepare new event data
699        $newEventData = [
700            'name' => $data['name'] ?? $sourceEvent->name . ' (Copy)',
701            'description' => $sourceEvent->description,
702            'eventType' => $sourceEvent->eventType,
703            'year' => $data['year'] ?? (int) date('Y'),
704            'startDate' => $data['startDate'] ?? null,
705            'endDate' => $data['endDate'] ?? null,
706            'buildUpDays' => $sourceEvent->buildUpDays,
707            'windDownDays' => $sourceEvent->windDownDays,
708            'color' => $sourceEvent->color,
709            'icon' => $sourceEvent->icon,
710            'isRecurring' => $sourceEvent->isRecurring,
711            'sourceEventId' => $sourceEvent->id,
712            'templateId' => $sourceEvent->templateId,
713            'status' => Event::STATUS_DRAFT,
714        ];
715
716        $this->db->beginTransaction();
717
718        try {
719            // Create the new event
720            $newEvent = $this->create($newEventData);
721
722            // Duplicate integrations if requested
723            if ($data['includeIntegrations'] ?? false) {
724                $integrations = $this->getIntegrations($eventId);
725                foreach ($integrations as $integration) {
726                    $sql = "INSERT INTO event_integrations (
727                        eventId, integrationType, foreignId, config, status, relativeDays, created_at
728                    ) VALUES (
729                        :eventId, :integrationType, :foreignId, :config, 'pending', :relativeDays, NOW()
730                    )";
731                    $stmt = $this->db->prepare($sql);
732                    $stmt->execute([
733                        ':eventId' => $newEvent->id,
734                        ':integrationType' => $integration->integrationType,
735                        ':foreignId' => $integration->foreignId,
736                        ':config' => $integration->config ? json_encode($integration->config) : null,
737                        ':relativeDays' => $integration->relativeDays,
738                    ]);
739                }
740            }
741
742            $this->logAudit($newEvent->id, 'duplicated', [
743                'sourceEventId' => $eventId,
744                'includeIntegrations' => $data['includeIntegrations'] ?? false,
745            ]);
746
747            $this->db->commit();
748
749            return $this->get($newEvent->id);
750        } catch (\Exception $e) {
751            $this->db->rollBack();
752            throw $e;
753        }
754    }
755
756    // =========================================================================
757    // CONFLICT CHECKING
758    // =========================================================================
759
760    /**
761     * Check for conflicts before create/update
762     *
763     * @param array $data Event data to check
764     * @param int|null $excludeEventId Event ID to exclude from conflict check (for updates)
765     * @return array ['hasConflicts' => bool, 'conflicts' => [], 'warnings' => []]
766     */
767    public function checkConflicts(array $data, ?int $excludeEventId = null): array
768    {
769        $conflicts = [];
770        $warnings = [];
771
772        // Only check conflicts if we have dates
773        if (empty($data['startDate']) || empty($data['endDate'])) {
774            return [
775                'hasConflicts' => false,
776                'conflicts' => [],
777                'warnings' => ['No date range specified; cannot check for conflicts'],
778            ];
779        }
780
781        $startDate = $data['startDate'];
782        $endDate = $data['endDate'];
783        $eventType = $data['eventType'] ?? Event::TYPE_CUSTOM;
784
785        // Check for overlapping events of the same type
786        $sql = "SELECT id, name, startDate, endDate, status
787                FROM events
788                WHERE eventType = :eventType
789                AND status NOT IN ('cancelled', 'archived')
790                AND (
791                    (startDate <= :endDate AND endDate >= :startDate)
792                )";
793        $params = [
794            ':eventType' => $eventType,
795            ':startDate' => $startDate,
796            ':endDate' => $endDate,
797        ];
798
799        if ($excludeEventId !== null) {
800            $sql .= " AND id != :excludeId";
801            $params[':excludeId'] = $excludeEventId;
802        }
803
804        $stmt = $this->db->prepare($sql);
805        $stmt->execute($params);
806
807        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
808            if ($row['status'] === Event::STATUS_ACTIVE) {
809                $conflicts[] = "Overlaps with active event '{$row['name']}' ({$row['startDate']} - {$row['endDate']})";
810            } else {
811                $warnings[] = "Overlaps with {$row['status']} event '{$row['name']}' ({$row['startDate']} - {$row['endDate']})";
812            }
813        }
814
815        return [
816            'hasConflicts' => !empty($conflicts),
817            'conflicts' => $conflicts,
818            'warnings' => $warnings,
819        ];
820    }
821
822    // =========================================================================
823    // PRIVATE HELPER METHODS
824    // =========================================================================
825
826    /**
827     * Log an audit entry for event changes
828     *
829     * @param int $eventId Event ID
830     * @param string $action Action performed
831     * @param array|null $details Additional details
832     */
833    private function logAudit(int $eventId, string $action, ?array $details = null): void
834    {
835        try {
836            $sql = "INSERT INTO event_audit_log (event_id, action, details, employee_id, created_at)
837                    VALUES (:eventId, :action, :details, :employeeId, NOW())";
838            $stmt = $this->db->prepare($sql);
839            $stmt->execute([
840                ':eventId' => $eventId,
841                ':action' => $action,
842                ':details' => $details ? json_encode($details) : null,
843                ':employeeId' => $this->employeeId,
844            ]);
845        } catch (PDOException $e) {
846            // Log failure should not break the main operation
847            error_log("EventService: Failed to log audit entry: " . $e->getMessage());
848        }
849    }
850
851    /**
852     * Validate a status transition
853     *
854     * @param Event $event Current event
855     * @param string $newStatus Target status
856     * @throws InvalidArgumentException If transition is not allowed
857     */
858    private function validateTransition(Event $event, string $newStatus): void
859    {
860        $allowedTransitions = self::STATUS_TRANSITIONS[$event->status] ?? [];
861
862        if (!in_array($newStatus, $allowedTransitions, true)) {
863            throw new InvalidArgumentException(
864                "Cannot transition from '{$event->status}' to '{$newStatus}'"
865            );
866        }
867    }
868
869    /**
870     * Recalculate and update event phase based on current dates
871     *
872     * @param Event $event Event to update
873     */
874    private function recalculatePhase(Event $event): void
875    {
876        $newPhase = $event->calculatePhase();
877
878        if ($newPhase !== $event->phase) {
879            $sql = "UPDATE events SET phase = :phase, updated_at = NOW() WHERE id = :id";
880            $stmt = $this->db->prepare($sql);
881            $stmt->execute([
882                ':id' => $event->id,
883                ':phase' => $newPhase,
884            ]);
885        }
886    }
887
888    /**
889     * Hydrate an Event object from input data array
890     *
891     * @param array $data Input data with camelCase keys
892     * @return Event Hydrated event object
893     */
894    private function hydrateEventFromData(array $data): Event
895    {
896        $event = new Event();
897
898        $event->templateId = isset($data['templateId']) ? (int) $data['templateId'] : null;
899        $event->sourceEventId = isset($data['sourceEventId']) ? (int) $data['sourceEventId'] : null;
900        $event->name = $data['name'] ?? '';
901        $event->description = $data['description'] ?? null;
902        $event->eventType = $data['eventType'] ?? Event::TYPE_CUSTOM;
903        $event->year = (int) ($data['year'] ?? date('Y'));
904        $event->startDate = $this->parseDateTime($data['startDate'] ?? null);
905        $event->endDate = $this->parseDateTime($data['endDate'] ?? null);
906        $event->buildUpDays = (int) ($data['buildUpDays'] ?? 14);
907        $event->windDownDays = (int) ($data['windDownDays'] ?? 7);
908        $event->status = $data['status'] ?? Event::STATUS_DRAFT;
909        $event->previousStatus = $data['previousStatus'] ?? null;
910        $event->phase = $event->calculatePhase();
911        $event->color = $data['color'] ?? null;
912        $event->icon = $data['icon'] ?? null;
913        $event->isRecurring = (bool) ($data['isRecurring'] ?? false);
914        $event->createdBy = isset($data['createdBy']) ? (int) $data['createdBy'] : null;
915
916        return $event;
917    }
918
919    /**
920     * Merge existing event data with update data
921     *
922     * @param Event $existing Existing event
923     * @param array $updates Update data
924     * @return array Merged data array
925     */
926    private function mergeEventData(Event $existing, array $updates): array
927    {
928        $existingArray = [
929            'templateId' => $existing->templateId,
930            'sourceEventId' => $existing->sourceEventId,
931            'name' => $existing->name,
932            'description' => $existing->description,
933            'eventType' => $existing->eventType,
934            'year' => $existing->year,
935            'startDate' => $existing->startDate?->format('Y-m-d H:i:s'),
936            'endDate' => $existing->endDate?->format('Y-m-d H:i:s'),
937            'buildUpDays' => $existing->buildUpDays,
938            'windDownDays' => $existing->windDownDays,
939            'status' => $existing->status,
940            'previousStatus' => $existing->previousStatus,
941            'phase' => $existing->phase,
942            'color' => $existing->color,
943            'icon' => $existing->icon,
944            'isRecurring' => $existing->isRecurring,
945            'createdBy' => $existing->createdBy,
946        ];
947
948        return array_merge($existingArray, $updates);
949    }
950
951    /**
952     * Parse a datetime string into a DateTime object
953     *
954     * @param string|null $value Datetime string or null
955     * @return DateTime|null Parsed DateTime or null
956     */
957    private function parseDateTime(?string $value): ?DateTime
958    {
959        if ($value === null || $value === '' || $value === '0000-00-00 00:00:00') {
960            return null;
961        }
962
963        try {
964            return new DateTime($value);
965        } catch (\Exception $e) {
966            return null;
967        }
968    }
969
970    /**
971     * Convert camelCase to snake_case
972     *
973     * @param string $input camelCase string
974     * @return string snake_case string
975     */
976    private function camelToSnake(string $input): string
977    {
978        return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $input));
979    }
980
981    /**
982     * Format a value for database storage
983     *
984     * @param string $field Field name
985     * @param mixed $value Value to format
986     * @return mixed Formatted value
987     */
988    private function formatValueForDb(string $field, $value)
989    {
990        // Handle DateTime fields
991        $dateFields = ['startDate', 'endDate', 'archivedAt'];
992        if (in_array($field, $dateFields)) {
993            if ($value instanceof DateTime) {
994                return $value->format('Y-m-d H:i:s');
995            }
996            return $value;
997        }
998
999        // Handle boolean fields
1000        $boolFields = ['isRecurring'];
1001        if (in_array($field, $boolFields)) {
1002            return $value ? 1 : 0;
1003        }
1004
1005        return $value;
1006    }
1007}