<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Theme lookup: strings + art (SPEC §10.4, §9.4 Ctx->theme).
 *
 * strings.ini values are raw pipe-coded templates ("section.key" addressing);
 * rendering happens at the Ctx/Pipe layer. Art slots resolve to .ans files
 * under data/themes/<name>/art/ (sysop copies) then themes/<name>/art/,
 * loaded through the CP437/SAUCE-aware loader. Missing art returns null and
 * callers fall back to generated templates (fail soft, §3.5).
 */
final class Theme
{
    /** Baked-in fallbacks: the board must survive a missing/broken theme file. */
    private const DEFAULTS = [
        'connect.welcome' => '|08%CT calls today · %CA calls ever · last caller: |07%LC',
        'connect.tagline' => '|08Node %ND · ATDT %VN · %DT %TM',
        'connect.banned' => '|12NO CARRIER -- this system is not accepting calls from your address.|07',
        'matrix.prompt' => '|07[|14L|07]ogin   [|14N|07]ew user   [|14G|07]oodbye',
        'matrix.hint' => '|03Command|08: |07',
        'matrix.invalid' => '|12That key does nothing here.|07',
        'matrix.busy' => '|12All nodes are busy right now. Try again in a few minutes.|07',
        'matrix.login_handle' => '|03Handle   |08: |15',
        'matrix.login_pass' => '|03Password |08: |15',
        'matrix.badlogin' => '|12Unknown handle or wrong password.|07',
        'matrix.badlogin_reset' => '|12Unknown handle or wrong password. |08Forgot it? |07R|08 here mails a reset link.|07',
        'matrix.reset_ask' => '|03Reset for|08: |15',
        'matrix.reset_note' => '|11PASSWORD RESET |08-- |07type the handle and ENTER mails its mailbox a one-time link.|07',
        'matrix.reset_sent_for' => '|07If |15%RH|07 has a mailbox on file, a letter is on its way.|07',
        'matrix.reset_sysop' => '|11SysOp accounts never reset by mail. |07Set a new password at the console.|07',
        'matrix.throttled' => '|12Too many tries. The line cools off for a while -- call back later.|07',
        'matrix.pending' => '|11Your application is still under review. The sysop will get to it soon.|07',
        'matrix.locked' => '|12This account is locked. Take it up with the sysop.|07',
        'matrix.already_online' => '|12That account is already on line on another node.|07',
        'matrix.closed' => '|11Sorry, this board is not taking new users right now.|07',
        'nua.title' => '|11NEW USER APPLICATION',
        'nua.intro' => '|07A few questions and the sysop will review your application.',
        'nua.intro2' => '|08Press ESC at any prompt to bail out.',
        'nua.ask_handle' => '|03Handle    |08: |15',
        'nua.ask_pass' => '|03Password  |08: |15',
        'nua.ask_pass2' => '|03Repeat it |08: |15',
        'nua.ask_location' => '|03Location  |08: |15',
        'nua.ask_email' => '|03Email     |08: |15',
        'nua.email_hint' => '|08For password resets. Only the SysOp sees it. ENTER skips.',
        'nua.bad_email' => "|12That doesn't look like an email address. |08ENTER alone skips it.|07",
        'nua.bad_handle' => '|12Handles are 3-20 characters: letters, digits, space, . _ - [ ]|07',
        'nua.banned_handle' => '|12That name will not fly on this board. Pick another.|07',
        'nua.banned_bye' => '|12Three prohibited names in a row. This line is no longer yours.|07',
        'nua.banned_ip' => '|08Your address |15%IP|08 has been noted in the log.|07',
        'nua.taken_handle' => '|12That handle is already spoken for. Pick another.|07',
        'nua.bad_pass' => '|12Passwords need at least 8 characters.|07',
        'nua.mismatch' => "|12Those two didn't match. Once more, from the top.|07",
        'nua.applied' => '|11Application received!|07 The sysop reviews every caller personally.',
        'nua.press_any_key' => '|08Press any key to return to the matrix.|07',
        'logon.title' => '|11CONNECTED -- |15%HN|11 ON THE LINE',
        'logon.row_handle' => '|03Handle     |08: |15%HN',
        'logon.row_level' => '|03Level      |08: |07%LV',
        'logon.row_node' => '|03Node       |08: |07%ND',
        'logon.row_calls' => '|03Calls      |08: |07%CL',
        'logon.row_posts' => '|03Posts      |08: |07%PS',
        'logon.motd' => '|08Caller %CT of the day. Wipe your feet, the board is freshly waxed.',
        'logon.rumor_title' => '|11RUMOR HAS IT...',
        'logon.automsg_title' => '|11AUTO-MESSAGE',
        'logon.automsg_by' => '|08-- left by |07%AB',
        'logon.mail_title' => '|11MAIL CALL',
        'logon.mail_body' => '|07You have |15%NL|07 new letter(s) waiting in the mail room.',
        'logon.mail_keys' => '|03Read them now? |08[|14Y|08] yes · any other key rolls on|07',
        'logon.email_title' => '|11A WAY TO REACH YOU',
        'logon.email_why' => '|07Leave an email and the board can mail you a password-reset link',
        'logon.email_private' => '|07if you ever lose it. |08Only the SysOp sees it. Never verified.|07',
        'logon.email_ask' => '|03Email |08(ENTER skips)|03: |15',
        'logon.email_bad' => "|12That doesn't look like an email address. |08ENTER alone skips.|07",
        'msg.no_area' => '|12No message areas are open to you. Talk to the sysop.|07',
        'msg.area_empty' => '|11Nothing posted here yet. Be the first -- [P]ost from the message menu.|07',
        'msg.more' => '|10More? |08[|14Y|08/|15n|08/|14=|08]|10  (page %PG)|07',
        'msg.reader_cmds' => '|07[|14N|07]ext [|14P|07]rev [|14R|07]eply [|14A|07]gain [|14T|07]hread [|14E|07]nter new [|14Q|07]uit',
        'msg.at_end' => '|11End of the area. [|14E|11]nter one yourself, or [|14Q|11]uit.|07',
        'msg.at_start' => '|11That was the first message.|07',
        'msg.no_thread' => '|11This message answers no other.|07',
        'msg.hdr_area' => '|03Area |08: |15%AR   |08· message |15%MN|08 of |15%MT',
        'msg.hdr_from' => '|03From |08: |15%FR   |03To |08: |07%TO',
        'msg.hdr_subject' => '|03Subj |08: |15%SU   |08%DA',
        'areas.title' => '|11MESSAGE AREAS',
        'areas.columns' => '|08     #  Tag        Area                            Msgs  Unread',
        'areas.pick' => '|03Area # |08(arrows pick · ENTER reads · ESC leaves)|03: |15',
        'areas.more_above' => '|14── %NA more above · |11PgUp|14 ──',
        'areas.more_below' => '|14── %NB more below · |11PgDn|14 ──',
        'areas.bad_pick' => '|12No area wears that number.|07',
        'areas.cat' => '|09■ |14%CN',
        'areas.other' => 'Other Boards',
        'areas.changed' => '|11You are now reading |15%AR|11.|07',
        'list.header' => '|03Area |08: |15%AR   |08%MT messages · |14%UN|08 unread',
        'list.columns' => '|08 Msg#  !  Subject                                         From           Date',
        'list.commands' => '|07[|14↑↓|07] move [|14Home|07/|14End|07] jump [|14Enter|07] read [|14E|07]nter new [|14S|07]ort [|14G|07]oto [|14A|07]rea [|14Q|07]uit',
        'list.goto' => '|03Msg # |08: |15',
        'list.bad_goto' => '|12No message bears that number or higher.|07',
        'compose.title_post' => '|11POST A MESSAGE',
        'compose.title_reply' => '|11POST A REPLY',
        'compose.title_mail' => '|11WRITE A LETTER',
        'compose.hint_esc' => '|08ESC at any prompt backs out.',
        'compose.ask_to' => '|03To |08(handle)|03 : |15',
        'compose.no_such_user' => '|12Nobody answers to that handle. Check the [U]ser list.|07',
        'compose.to_line' => '|03Letter to |08: |15%TO',
        'compose.in_area' => '|03Posting in |08: |15%AR',
        'compose.ask_subject' => '|03Subject    |08: |15',
        'compose.editor_title_post' => 'Posting in %AR - ^S saves, ESC aborts',
        'compose.editor_title_reply' => 'Replying in %AR - ^S saves, ESC aborts, ^Q quotes',
        'compose.editor_title_mail' => 'Letter to %AR - ^S sends, ESC aborts',
        'compose.posted' => '|11Posted! Filed as message #%MN. The wires hum with your words.|07',
        'compose.sent_mail' => "|11Letter sealed and slotted. They'll see it next call.|07",
        'compose.aborted' => '|11Message tossed in the wastebasket.|07',
        'mail.title' => '|11THE MAIL ROOM',
        'mail.empty' => '|07Your slot is empty. |08Nobody writes anymore.|07',
        'mail.columns' => '|08    #   From              Subject                        Date (%PG)',
        'mail.pick' => '|03Letter # |08(W writes, S sent/inbox, N/P page, Q quits)|03: |15',
        'mail.pick_empty' => '|03Press |08W|03 to write, |08S|03 for sent/inbox, |08Enter|03 to leave: |15',
        'mail.bad_pick' => '|12No letter wears that number.|07',
        'mail.hdr_from' => '|03From |08: |15%FR   |08%DA',
        'mail.hdr_subject' => '|03Subj |08: |15%SU',
        'mail.read_cmds' => '|07[|14R|07]eply   [|14B|07]ack to list   [|14Q|07]uit',
        'mail.read_more' => '|10Page %PG   |08[|14N|08]ext page · [|14P|08]revious|07',
        'mail.title_sent' => '|11THE MAIL ROOM |08-- letters you sent',
        'mail.empty_sent' => "|07You haven't sent any letters yet.|07",
        'mail.columns_sent' => '|08    #   To                Subject                        Date (%PG)',
        'mail.hdr_to' => '|03To   |08: |15%FR   |08%DA',
        'scan.stopped' => '|11Scan stopped. The rest will keep.|07',
        'scan.done' => '|11New scan complete -- you are all caught up.|07',
        'scan.nothing' => '|11Nothing new anywhere. The board sleeps.|07',
        'pause.more' => '|10-- More --|07',
        'menu.prompt' => '|03Command|08: |07',
        'menu.status' => '|03Node|08: |07%ND   |15%HN',
        'menu.invalid' => '|12No such command here. Press |14?|12 to redraw the menu.|07',
        'menu.area_missing' => '|12That message area is not on this board any more.|07',
        'menu.area_denied' => '|12That message area is not open to you.|07',
        'menu.soon' => '|11That part of the board opens with a later build. The wires are humming.|07',
        'menu.door_soon' => '|11The door games hall is still under construction -- opens with a later build.|07',
        'logoff.confirm' => '|03Hang up? |08[|14y|08/|15N|08]|07 ',
        'chat.title' => 'CHAT: everyone on the wire hears you. Empty line hangs it up.',
        'chat.hint' => '|08Enter sends · empty line leaves · PgUp/PgDn or Up/Down arrows browse history',
        'chat.prompt' => '|14> |15',
        'sysopchat.title' => 'PRIVATE LINE: just you and the sysop. Nobody else hears this.',
        'sysopchat.hint' => '|08Enter sends · empty line hangs up and returns you where you were',
        'sysopchat.breakin' => '|12*** |15The SysOp breaks in to chat with you |12***',
        'sysopchat.answered' => '|12*** |10The SysOp picks up your page |12***',
        'sysopchat.left' => '|12*** |15The SysOp hangs up the line |08-- an empty line returns you to the board |12***',
        'sysopchat.away' => '|12*** |07The console has gone quiet -- the SysOp may have stepped away |12***',
        'sysopchat.back' => '|12*** |10The SysOp is back at the console |12***',
        'sysopchat.split_top' => 'you',
        'sysopchat.split_bottom' => 'the SysOp',
        'page.title' => '|11PAGE THE SYSOP',
        'page.blurb' => '|07This rings the bell in the back room. One line: what do you need?',
        'page.ask' => '|03Reason |08: |15',
        'page.ringing' => '|11Ringing the back-room bell for the sysop',
        'page.here_hint' => '|07The console says the sysop is |10around|07. Give it a minute.',
        'page.away_hint' => '|07The console says the sysop looks |12away|07 right now. It may be a long ring.',
        'page.wait_hint' => '|08Any key stops waiting -- the page stays lit on the sysop console.',
        'page.answered' => '|10The sysop picks up! You are in chat.',
        'page.timeout' => '|12No pickup this time.|07 The page stays lit -- a late pickup still rings you.',
        'page.timeout_keys' => '|08Press any key to head back.|07',
        'page.paged_notice' => '|14*** |12%PB|14 is paging you|07 -- answer from the sysop console',
        'one.title' => '|11ONELINERS',
        'one.blurb' => '|08The graffiti wall: quick one-liners from callers, latest 15 on show.',
        'one.empty' => '|07Nobody has left a oneliner yet. The wall is yours.|07',
        'one.prompt' => '|07[|14A|07]dd yours   [|14Q|07]uit',
        'one.ask' => '|03Your line |08(70 max)|03: |15',
        'one.added' => '|11Chiseled into the wall. One per day, make it count.|07',
        'one.rate' => '|12One per caller per day -- you already left your mark. Tomorrow!|07',
        'rumor.title' => '|11THE RUMOR MILL',
        'rumor.blurb' => '|08Overheard on the wire. One random rumor greets every caller at logon.',
        'rumor.empty' => '|07No rumors yet. Surely you have heard SOMETHING...|07',
        'rumor.prompt' => '|07[|14A|07]dd one   [|14Q|07]uit',
        'rumor.ask' => '|03They say |08...|03 |15',
        'rumor.added' => '|11Duly noted. A random caller hears it at every logon.|07',
        'rumor.pile' => '|07The mill is grinding |15%RC|07 rumor(s). One greets each caller at logon.',
        'rumor.tease' => '|08Which one? That would be telling. Add your own and let it spread.|07',
        'auto.title' => '|11THE AUTO-MESSAGE WALL',
        'auto.empty' => '|07The wall is blank. Somebody paint it.|07',
        'auto.by' => '|08-- painted by |07%AB|08 on %AT',
        'auto.prompt' => '|07[|14Q|07]uit',
        'auto.prompt_edit' => '|07[|14E|07] paint a fresh wall |08(it replaces this one)|07   [|14Q|07]uit',
        'auto.editor_title' => 'A fresh coat, 8 lines - every caller sees it at logon - ^S paints',
        'auto.saved' => '|11The wall is repainted. Every caller sees it at logon.|07',
        'vote.title' => '|11THE VOTING BOOTH',
        'vote.empty' => '|07No questions on the ballot. Democracy sleeps.|07',
        'vote.columns' => '|08  #  Voted  Question',
        'vote.pick' => '|03Question # |08(Enter quits)|03: |15',
        'vote.pick_sysop' => '|03Question # |08(N new, D# delete, Enter quits)|03: |15',
        'vote.ask_q' => '|03Question |08: |15',
        'vote.ask_opt' => '|03Option %NO |08(blank line finishes)|03: |15',
        'vote.need_two' => '|12Two options minimum -- keep them coming.|07',
        'vote.added' => '|11The booth has a new question. Democracy hums.|07',
        'vote.deleted' => '|11Question and its ballots removed.|07',
        'vote.pick_empty' => '|03Press |08Enter|03 to leave: |15',
        'vote.bad_pick' => '|12No question wears that number.|07',
        'vote.choice' => '|03Your choice |08#|03: |15',
        'bbs.title' => '|11OTHER BOARDS WORTH A CALL',
        'bbs.empty' => '|07The directory is empty. Know a good board? [A]dd it.|07',
        'bbs.columns' => '|08 #  Name                     Address',
        'bbs.prompt' => '|07[|14A|07]dd a board   [|14N|07]ext [|14P|07]rev page   [|14Q|07]uit',
        'bbs.prompt_sysop' => '|07[|14A|07]dd   [|14D|07]elete   [|14N|07]ext [|14P|07]rev   [|14Q|07]uit',
        'bbs.ask_del' => '|03Entry # to remove |08: |15',
        'bbs.deleted' => '|11Entry removed from the directory.|07',
        'bbs.ask_name' => '|03Board name |08: |15',
        'bbs.ask_host' => '|03Address |08: |15',
        'bbs.host_hint' => '|08A web URL https://... , a telnet URL telnet://... , or host:port|07',
        'bbs.added' => '|11Added to the directory. Tell them ATDT sent you.|07',
        'bbs.bad_host' => '|12That address does not look right: https://... , telnet://... , or host:port.|07',
        'bbs.no_add' => '|12Adding to the directory is restricted on this board. You can still browse.|07',
        'bull.title' => '|11BULLETINS',
        'bull.empty' => '|07No bulletins posted.|07',
        'bull.pick' => '|03Bulletin # |08(Enter quits)|03: |15',
        'bull.bad_pick' => '|12No bulletin wears that number.|07',
        'users.title' => '|11THE USER LIST',
        'users.columns' => '|08Handle               Location                 Lvl Calls  Last on   (%PG)',
        'users.prompt' => '|07[|14N|07]ext page   [|14P|07]rev page   [|14Q|07]uit',
        'callers.title' => '|11LAST 10 CALLERS',
        'callers.columns' => '|08Handle             Nd  When             Off    Doing',
        'callers.empty' => '|07The log is empty -- you are the first footprints in the snow.|07',
        'online.title' => '|11WHO IS ON THE WIRE',
        'online.columns' => '|08  Nd  Handle               Where',
        'online.logging_in' => '(logging in)',
        'stats.title' => '|11BOARD STATS',
        'stats.row_founded' => '|03Founded        |08: |07%SV',
        'stats.row_calls' => '|03Calls, all time|08: |07%SV',
        'stats.row_today' => '|03Calls today    |08: |07%SV',
        'stats.row_users' => '|03Users          |08: |07%SV',
        'stats.row_posts' => '|03Posts          |08: |07%SV',
        'stats.top_title' => '|11TOP 5 POSTERS',
        'stats.top_empty' => '|08Nobody has posted yet. The soapbox is free.|07',
        'set.title' => '|11YOUR SETTINGS',
        'set.row_beep' => '|07[|14B|07] Terminal bell    |08: |15%BP',
        'set.row_baud' => '|07[|14S|07] Connection speed |08: |15%BD',
        'set.row_email' => '|07[|14E|07] Email on file    |08: |15%EM',
        'set.row_pass' => '|07[|14P|07] Change password',
        'set.baud_note' => '|08Slower speeds are pure nostalgia. Any keypress skips ahead.',
        'set.email_note' => '|08Password resets go to your email. Only the SysOp ever sees it.',
        'set.email_none' => 'none',
        'set.prompt' => '|07[|14B|07]eep  [|14S|07]peed  [|14E|07]mail  [|14P|07]assword  [|14Q|07]uit',
        'set.state_on' => 'on',
        'set.state_off' => 'off',
        'set.baud_off' => 'no throttle',
        'set.ask_old' => '|03Current password |08: |15',
        'set.ask_new' => '|03New password |08(8+)|03: |15',
        'set.ask_new2' => '|03Repeat it        |08: |15',
        'set.bad_old' => '|12That is not your current password.|07',
        'set.mismatch' => '|12Those two didn\'t match. Unchanged.|07',
        'set.pass_changed' => '|11Password changed. Don\'t write it on the monitor.|07',
        'set.ask_email' => '|03Email |08(blank clears)|03: |15',
        'set.email_bad' => "|12That doesn't look like an email address. Unchanged.|07",
        'set.email_saved' => '|11Email saved. Only the SysOp sees it.|07',
        'set.email_cleared' => '|11Email cleared from your account.|07',
        'help.title' => '|11CODES OF THE BOARD |08-- colors and live text',
        'help.more' => '|08Any key for the MCI codes page · Q quits.|07',
        'help.done' => '|08Any key returns to the menu.|07',
        'esys.title' => '|11A LETTER TO THE SYSOP',
        'esys.blurb1' => "|07This goes out as real email, straight to the SysOp's inbox.",
        'esys.blurb2' => '|08One letter per day. ENTER alone changes your mind.',
        'esys.ask_subject' => '|03Subject |08: |15',
        'esys.editor_title' => 'Your letter to the SysOp',
        'esys.sent' => '|11Sent. The SysOp reads it off the board -- watch your email for a reply.|07',
        'esys.fail' => '|12The mail machine jammed -- the letter did not go out. Try again later.|07',
        'esys.mail_off' => '|07Outgoing mail is switched off right now. Try a page or the mail room.|07',
        'esys.rate' => '|07You have already written the SysOp today. The wire rests until tomorrow.|07',
        'logon.bull_title' => '|11BULLETIN BOARD -- FRESH INK',
        'logon.bull_more' => '|08More in [|07B|08]ulletins from the main menu.',
        'goodbye.text' => '|07Thanks for calling |15%BN|07 -- come back soon.',
        'goodbye.nocarrier' => '|08+++ NO CARRIER',
    ];

    private string $name;
    /** @var array<string, string>|null */
    private ?array $strings = null;
    /** @var array<string, string>|null */
    private ?array $artMap = null;

    public function __construct(string $name = 'default')
    {
        // Theme names are directory names; keep them boring.
        $this->name = preg_match('/^[A-Za-z0-9_-]{1,32}$/', $name) === 1 ? $name : 'default';
    }

    public function name(): string
    {
        return $this->name;
    }

    /** Raw pipe-coded theme string ("section.key"); render via Pipe. */
    public function str(string $key): string
    {
        $all = $this->load();
        return $all[$key] ?? self::DEFAULTS[$key] ?? $key;
    }

    /**
     * Every string key the engine knows, with its baked-in default, in
     * display order. The console strings editor lists THIS catalog (not
     * a theme's file), so a copy made under an older release still
     * shows and can customize every current key (1.7.0).
     * @return array<string, string> "section.key" => default value
     */
    public static function catalog(): array
    {
        return self::DEFAULTS;
    }

    /** The board-drawn screens a theme may replace with gallery art. */
    public const BUILTIN_SLOTS = ['connect', 'logon', 'goodbye'];

    /**
     * Load a theme art slot ("connect", "goodbye", …) as a Unicode ANSI
     * string. Since 1.1.0 a theme binds slots to art-gallery pieces in
     * its art.json; per-theme .ans files remain as a legacy fallback so
     * a half-upgraded board keeps its face.
     */
    public function art(string $slot): ?string
    {
        if (preg_match('/^[A-Za-z0-9_-]{1,32}$/', $slot) !== 1) {
            return null;
        }
        $piece = $this->artMap()[$slot] ?? '';
        if ($piece !== '') {
            $art = Art::load($piece);
            if ($art !== null) {
                return $art;
            }
        }
        foreach ([
            ATDT_ROOT . '/data/themes/' . $this->name . '/art/' . $slot . '.ans',
            ATDT_ROOT . '/themes/' . $this->name . '/art/' . $slot . '.ans',
        ] as $file) {
            $art = Cp437::loadAnsFile($file);
            if ($art !== null) {
                return $art;
            }
        }
        return null;
    }

    /**
     * The slot -> gallery-piece bindings from art.json (data overlay
     * first, shipped second). Missing file or key means "generated
     * default" -- the board draws its own screen.
     *
     * @return array<string, string>
     */
    public function artMap(): array
    {
        if ($this->artMap !== null) {
            return $this->artMap;
        }
        $this->artMap = [];
        foreach ([
            ATDT_ROOT . '/data/themes/' . $this->name . '/art.json',
            ATDT_ROOT . '/themes/' . $this->name . '/art.json',
        ] as $file) {
            if (!is_file($file)) {
                continue;
            }
            $map = json_decode((string)@file_get_contents($file), true);
            if (!is_array($map)) {
                continue; // fail soft: broken file, generated screens cover us
            }
            foreach ($map as $slot => $piece) {
                if (is_string($piece) && preg_match(Art::NAME_RE, $piece) === 1) {
                    $this->artMap[(string)$slot] = $piece;
                }
            }
            break; // first hit wins (data/themes overrides themes/)
        }
        return $this->artMap;
    }

    /** @return array<string, string> */
    private function load(): array
    {
        if ($this->strings !== null) {
            return $this->strings;
        }
        $this->strings = [];
        foreach ([
            ATDT_ROOT . '/data/themes/' . $this->name . '/strings.ini',
            ATDT_ROOT . '/themes/' . $this->name . '/strings.ini',
        ] as $file) {
            if (!is_file($file)) {
                continue;
            }
            $ini = @parse_ini_file($file, true, INI_SCANNER_NORMAL);
            if (!is_array($ini)) {
                continue; // fail soft (§3.5): broken file, defaults cover us
            }
            foreach ($ini as $section => $pairs) {
                if (!is_array($pairs)) {
                    continue;
                }
                foreach ($pairs as $k => $v) {
                    $this->strings["$section.$k"] = (string)$v;
                }
            }
            break; // first hit wins (data/themes overrides themes/)
        }
        return $this->strings;
    }
}
