Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatTemplateService
0.00% covered (danger)
0.00%
0 / 87
0.00% covered (danger)
0.00%
0 / 12
1056
0.00% covered (danger)
0.00%
0 / 1
 render
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renderString
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 buildReplacementMap
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getCustomerFirstName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 getCustomerLastName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 getTodayHours
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatPhone
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 getAvailableWildcards
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 calculateSegmentCount
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 containsUnicodeCharacters
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateTemplate
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 findUnknownWildcards
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Chat\Services;
4
5use BuyerKiosk\Chat\Models\ChatTemplate;
6use BuyerKiosk\Core\Store;
7use BuyerKiosk\Core\Customer;
8use BuyerKiosk\Core\Buy;
9
10/**
11 * ChatTemplateService
12 *
13 * Handles template wildcard processing and SMS segment calculations for Two-Way SMS Chat.
14 *
15 * Key Responsibilities:
16 * - Render templates with wildcard substitution ({{customer_name}}, etc.)
17 * - Calculate SMS segment count based on GSM-7 or Unicode encoding
18 * - Validate templates against character limits (max 320 chars)
19 * - Provide wildcard documentation for UI
20 *
21 * Per SDD lines 939-989:
22 * - Wildcards: customer_name, customer_full_name, store_name, store_phone,
23 *   store_address, store_city, store_hours, buy_number, buy_date
24 *
25 * Per PRD lines 173-180:
26 * - Maximum 320 characters per message (2 SMS segments)
27 * - Character count and SMS segment count shown during editing
28 *
29 * @package BuyerKiosk\Chat\Services
30 */
31class ChatTemplateService
32{
33    // Maximum character limit per PRD
34    private const MAX_CHARACTERS = 320;
35
36    // SMS segment sizes
37    private const GSM_SINGLE_SEGMENT = 160;
38    private const GSM_MULTI_SEGMENT = 153;
39    private const UNICODE_SINGLE_SEGMENT = 70;
40    private const UNICODE_MULTI_SEGMENT = 67;
41
42    // GSM-7 Basic Character Set (simplified regex pattern)
43    // Includes: A-Z, a-z, 0-9, standard punctuation, and GSM extended chars
44    private const GSM7_PATTERN = '/^[@\x{00A3}\x{00A5}\x{00E8}\x{00E9}\x{00F9}\x{00EC}\x{00F2}'
45        . '\x{00C7}\n\x{00D8}\x{00F8}\r\x{00C5}\x{00E5}\x{0394}\x{005F}'
46        . '\x{03A6}\x{0393}\x{039B}\x{03A9}\x{03A0}\x{03A8}\x{03A3}'
47        . '\x{0398}\x{039E}\x{00C6}\x{00E6}\x{00DF}\x{00C9} !"#\$%&\'()*+,'
48        . '\-.\/0-9:;<=>?\x{00A1}A-Z\x{00C4}\x{00D6}\x{00D1}\x{00DC}'
49        . '\x{00A7}\x{00BF}a-z\x{00E4}\x{00F6}\x{00F1}\x{00FC}\x{00E0}'
50        . '\^{}\\\[~\]\|\x{20AC}]*$/u';
51
52    /**
53     * Valid wildcards supported by the template system
54     */
55    private const VALID_WILDCARDS = [
56        '{{customer_name}}',
57        '{{customer_full_name}}',
58        '{{store_name}}',
59        '{{store_phone}}',
60        '{{store_address}}',
61        '{{store_city}}',
62        '{{store_hours}}',
63        '{{buy_number}}',
64        '{{buy_date}}',
65    ];
66
67    /**
68     * Render a template with wildcard substitution
69     *
70     * Replaces all supported wildcards with actual values from customer, store, and buy data.
71     * Missing customer name falls back to "Customer".
72     * Buy wildcards remain as placeholders if no buy is provided.
73     *
74     * @param ChatTemplate $template The template to render
75     * @param Customer|object $customer Customer object with getFirstName(), getLastName()
76     * @param Store|object $store Store object with getCompanyName(), getPhone(), etc.
77     * @param Buy|object|null $buy Optional buy object with getDailyNum(), getTimeEntered()
78     * @return string Rendered message content
79     */
80    public function render(
81        ChatTemplate $template,
82        $customer,
83        $store,
84        $buy = null
85    ): string {
86        return $this->renderString($template->getContent(), $customer, $store, $buy);
87    }
88
89    /**
90     * Render a string template with wildcard substitution (for previews)
91     *
92     * Useful for rendering preview content without a ChatTemplate object.
93     *
94     * @param string $content Template content with wildcards
95     * @param Customer|object $customer Customer object
96     * @param Store|object $store Store object
97     * @param Buy|object|null $buy Optional buy object
98     * @return string Rendered message content
99     */
100    public function renderString(
101        string $content,
102        $customer,
103        $store,
104        $buy = null
105    ): string {
106        // Build replacement map
107        $replacements = $this->buildReplacementMap($customer, $store, $buy);
108
109        // Perform replacements
110        foreach ($replacements as $wildcard => $value) {
111            $content = str_replace($wildcard, $value, $content);
112        }
113
114        return $content;
115    }
116
117    /**
118     * Build the wildcard replacement map
119     *
120     * @param Customer|object $customer
121     * @param Store|object $store
122     * @param Buy|object|null $buy
123     * @return array Wildcard => value mapping
124     */
125    private function buildReplacementMap($customer, $store, $buy = null): array
126    {
127        // Customer wildcards
128        $firstName = $this->getCustomerFirstName($customer);
129        $lastName = $this->getCustomerLastName($customer);
130        $fullName = trim($firstName . ' ' . $lastName);
131
132        $replacements = [
133            '{{customer_name}}' => $firstName ?: 'Customer',
134            '{{customer_full_name}}' => $fullName ?: 'Customer',
135            '{{store_name}}' => $store->getCompanyName() ?? '',
136            '{{store_phone}}' => $this->formatPhone($store->getPhone() ?? ''),
137            '{{store_address}}' => $store->getAddress() ?? '',
138            '{{store_city}}' => $store->getCity() ?? '',
139            '{{store_hours}}' => $this->getTodayHours($store),
140        ];
141
142        // Buy wildcards (only if buy provided)
143        if ($buy !== null) {
144            $replacements['{{buy_number}}'] = '#' . $buy->getDailyNum();
145            $replacements['{{buy_date}}'] = date('m/d', strtotime($buy->getTimeEntered()));
146        }
147
148        return $replacements;
149    }
150
151    /**
152     * Get customer first name safely
153     *
154     * @param Customer|object $customer
155     * @return string|null
156     */
157    private function getCustomerFirstName($customer): ?string
158    {
159        $firstName = $customer->getFirstName();
160        return (is_string($firstName) && trim($firstName) !== '') ? $firstName : null;
161    }
162
163    /**
164     * Get customer last name safely
165     *
166     * @param Customer|object $customer
167     * @return string|null
168     */
169    private function getCustomerLastName($customer): ?string
170    {
171        $lastName = $customer->getLastName();
172        return (is_string($lastName) && trim($lastName) !== '') ? $lastName : null;
173    }
174
175    /**
176     * Get today's store hours
177     *
178     * Note: This is a placeholder implementation. In production, this would
179     * query the store's hours configuration for the current day.
180     *
181     * @param Store|object $store
182     * @return string Hours string like "9am - 9pm"
183     */
184    private function getTodayHours($store): string
185    {
186        // Placeholder - would need to look up actual store hours
187        // For now, return a sensible default
188        return '9am - 9pm';
189    }
190
191    /**
192     * Format phone number as readable (XXX) XXX-XXXX
193     *
194     * @param string $phone Raw phone number
195     * @return string Formatted phone number
196     */
197    public function formatPhone(string $phone): string
198    {
199        // Strip all non-digits
200        $digits = preg_replace('/\D/', '', $phone);
201
202        // Handle empty input
203        if ($digits === '' || $digits === null) {
204            return '';
205        }
206
207        // Remove country code if present (11 digits starting with 1)
208        if (strlen($digits) === 11 && $digits[0] === '1') {
209            $digits = substr($digits, 1);
210        }
211
212        // Only format 10-digit numbers
213        if (strlen($digits) !== 10) {
214            return $phone; // Return as-is if not 10 digits
215        }
216
217        // Format as (XXX) XXX-XXXX
218        return sprintf(
219            '(%s) %s-%s',
220            substr($digits, 0, 3),
221            substr($digits, 3, 3),
222            substr($digits, 6, 4)
223        );
224    }
225
226    /**
227     * Get available wildcards with descriptions
228     *
229     * Returns an associative array mapping wildcard syntax to human-readable descriptions.
230     * Used for documenting wildcards in the UI.
231     *
232     * @return array Wildcard => description mapping
233     */
234    public function getAvailableWildcards(): array
235    {
236        return [
237            '{{customer_name}}' => 'Customer first name',
238            '{{customer_full_name}}' => 'Customer full name',
239            '{{store_name}}' => 'Store name',
240            '{{store_phone}}' => 'Store phone number',
241            '{{store_address}}' => 'Store street address',
242            '{{store_city}}' => 'Store city',
243            '{{store_hours}}' => "Today's hours",
244            '{{buy_number}}' => 'Buy daily number (e.g., #47)',
245            '{{buy_date}}' => 'Buy date',
246        ];
247    }
248
249    /**
250     * Calculate the number of SMS segments required for a message
251     *
252     * SMS messages are segmented based on character encoding:
253     * - GSM-7 (standard chars): 160 chars for single, 153 for multipart
254     * - Unicode (emojis, special): 70 chars for single, 67 for multipart
255     *
256     * @param string $message The message content
257     * @return int Number of SMS segments (0 for empty message)
258     */
259    public function calculateSegmentCount(string $message): int
260    {
261        $length = mb_strlen($message, 'UTF-8');
262
263        if ($length === 0) {
264            return 0;
265        }
266
267        // Check if message requires Unicode encoding
268        $isUnicode = $this->containsUnicodeCharacters($message);
269
270        if ($isUnicode) {
271            // Unicode encoding
272            if ($length <= self::UNICODE_SINGLE_SEGMENT) {
273                return 1;
274            }
275            return (int) ceil($length / self::UNICODE_MULTI_SEGMENT);
276        }
277
278        // GSM-7 encoding
279        if ($length <= self::GSM_SINGLE_SEGMENT) {
280            return 1;
281        }
282        return (int) ceil($length / self::GSM_MULTI_SEGMENT);
283    }
284
285    /**
286     * Check if message contains characters outside GSM-7 character set
287     *
288     * @param string $message
289     * @return bool True if message requires Unicode encoding
290     */
291    private function containsUnicodeCharacters(string $message): bool
292    {
293        return !preg_match(self::GSM7_PATTERN, $message);
294    }
295
296    /**
297     * Validate a template against business rules
298     *
299     * Checks:
300     * - Character count within limit (320 max)
301     * - Content is not empty
302     * - Warns about unknown wildcards
303     *
304     * @param string $content Template content to validate
305     * @return array Validation result with keys: valid, charCount, segments, errors, warnings
306     */
307    public function validateTemplate(string $content): array
308    {
309        $charCount = mb_strlen($content, 'UTF-8');
310        $segments = $this->calculateSegmentCount($content);
311        $errors = [];
312        $warnings = [];
313
314        // Check for empty content
315        if ($charCount === 0) {
316            $errors[] = 'Template content is required';
317        }
318
319        // Check character limit
320        if ($charCount > self::MAX_CHARACTERS) {
321            $errors[] = sprintf(
322                'Content exceeds maximum of %d characters (current: %d)',
323                self::MAX_CHARACTERS,
324                $charCount
325            );
326        }
327
328        // Check for unknown wildcards
329        $unknownWildcards = $this->findUnknownWildcards($content);
330        if (!empty($unknownWildcards)) {
331            $warnings[] = 'Unknown wildcards: ' . implode(', ', $unknownWildcards);
332        }
333
334        return [
335            'valid' => empty($errors),
336            'charCount' => $charCount,
337            'segments' => $segments,
338            'errors' => $errors,
339            'warnings' => $warnings,
340        ];
341    }
342
343    /**
344     * Find wildcards in content that are not in the valid list
345     *
346     * @param string $content
347     * @return array List of unknown wildcards
348     */
349    private function findUnknownWildcards(string $content): array
350    {
351        preg_match_all('/\{\{([a-z_]+)\}\}/', $content, $matches);
352        $foundWildcards = $matches[0] ?? [];
353
354        return array_values(array_diff($foundWildcards, self::VALID_WILDCARDS));
355    }
356}