
import { defineStore } from 'pinia'

import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps'
import type { DeviceBootstrap, PhoneDevice } from '@/types/device'
import { clampPage } from '@/utils/pages'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
import {
  DEFAULT_PHONE_PREFERENCES,
  parsePhonePreferences,
  type AppNotificationPreferences,
  type PhonePreferencesV1,
  type WallpaperId,
} from '@/utils/preferences'

type LocaleTree = Record<string, unknown>

export type PhoneOpenPayload = {
  account?: DeviceBootstrap['account']
  device?: PhoneDevice
  lang?: string
  locales?: LocaleTree
  notes?: DeviceBootstrap['notes']
  token?: string
}

const namespaceQueues = new Map<string, Promise<void>>()

const defaultLocales: LocaleTree = {
  Apps: {
    darkchat: {
      name: 'DarkChat', newMessage: 'New DarkChat message from {sender}', privateNotification: 'New DarkChat message',
      signInBody: 'DarkChat identities are linked to your private iFruit account.', signInHint: 'Sign in through Settings to continue.',
      security: 'Security', privateNetwork: 'Private invitation-only network', noResults: 'No Results', noResultsBody: 'Try another alias or Dark-ID.',
      noChats: 'No Private Chats', noChatsBody: 'Connect with a Dark-ID or invitation code. There is no public search.', newChat: 'New Chat',
      connectPrivately: 'Connect privately', newChatBody: 'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.', darkIdOrInvite: 'Dark-ID or invitation code', continue: 'Continue', contacts: 'DarkChat Contacts', shareIdentity: 'Tap to share your private identity',
      message: 'Dark message', activeNow: 'Activity shared', encryptedSession: 'Private session', serverPrivate: 'Private server-stored conversation',
      emoji: 'Emoji', gif: 'GIF', gifs: 'GIFs', photo: 'Photo', video: 'Video', attachPhoto: 'Attach Photo', takePhoto: 'Take Photo', attachGif: 'Attach GIF', attachVideo: 'Attach Video', searchGifs: 'Search GIFs', loadMore: 'Load More',
      voiceMessage: 'Voice message', sending: 'Sending', failed: 'Not delivered', delivered: 'Delivered', read: 'Read', replying: 'Replying to',
      messageDeleted: 'Message deleted', securityUpdate: 'Security settings updated', timerChanged: 'Disappearing messages: {timer}',
      timerOff: 'Off', timerAfterRead: 'After reading', timerMinute: '1 minute', timerFiveMinutes: '5 minutes', timerHour: '1 hour', timerDay: '24 hours', timerWeek: '7 days',
      copied: 'Copied', reply: 'Reply', copy: 'Copy', deleteForMe: 'Delete for me', deleteForBoth: 'Delete for both', report: 'Report',
      contactSecurity: 'Contact & Security', chatSince: 'Private chat since {date}', notifications: 'Notifications', readReceipts: 'Read receipts', disappearing: 'Disappearing messages', contactAlias: 'Contact alias', saveContact: 'Save Contact', addContact: 'Add Contact', contactSaved: 'Contact saved', removeContact: 'Remove Contact', block: 'Block User', unblock: 'Unblock User', clearChat: 'Clear Chat', chatCleared: 'Chat cleared',
      myIdentity: 'My Dark Identity', alias: 'Alias', notificationPrivacy: 'Notification privacy', notificationFull: 'Full · alias and message', notificationPrivate: 'Private · generic message', notificationHidden: 'Invisible · badge only', shareActivity: 'Share activity status', inviteCode: 'Private invitation code', copyInvite: 'Copy Invite', privacyDisclaimer: 'DarkChat stores messages on the server and does not claim end-to-end encryption.',
      unknownIdentity: 'Unknown Identity', unknownIdentityBody: 'Only continue if you expected this identity. The account is not discoverable through public search.', openSecureChat: 'Open Private Chat',
      reportUser: 'Report User', reportSpam: 'Spam', reportHarassment: 'Harassment', reportThreats: 'Threats', reportIllegal: 'Illegal content', reportOther: 'Other', reportDetails: 'Optional details', submitReport: 'Submit Report', reported: 'Report submitted',
      microphoneUnavailable: 'The microphone is unavailable.', recordingTooLarge: 'The voice message is too large.',
      errors: {
        not_authenticated: 'Sign in to your iFruit account first.', invalid_dark_id: 'Enter a valid Dark-ID or invitation code.', profile_not_found: 'This private identity was not found.', self_chat: 'You cannot message your own identity.', conversation_not_found: 'This conversation is unavailable.', blocked: 'Messages are blocked in this conversation.', invalid_message: 'Enter a valid message.', invalid_gif: 'This GIF is invalid.', invalid_voice: 'This voice message is invalid.', invalid_attachment: 'This photo or video is unavailable.', invalid_profile: 'Check your alias and privacy settings.', rate_limited: 'Too many requests. Try again shortly.', gif_provider_unconfigured: 'GIF search is not configured.', gif_provider_unauthorized: 'The GIF provider key is invalid.', gif_provider_rate_limited: 'GIF search is busy. Try again shortly.', gif_provider_failed: 'GIFs are temporarily unavailable.', default: 'DarkChat could not complete the request.',
      },
    },
    messages: {
      name: 'Messages',
      newMessage: 'New message from {sender}',
      compose: 'New Message',
      search: 'Search',
      to: 'To:',
      message: 'Message',
      send: 'Send',
      details: 'Details',
      filterUnread: 'Show Unread Messages',
      smsLabel: 'Text Message · SMS',
      photo: 'Photo',
      gif: 'GIF',
      video: 'Video',
      attachPhoto: 'Attach Photo',
      takePhoto: 'Take Photo',
      attachGif: 'Attach GIF',
      attachVideo: 'Attach Video',
      photos: 'Photos',
      gifs: 'GIFs',
      videos: 'Videos',
      noPhotos: 'Take a photo first to attach it here.',
      noVideos: 'Record a video in Camera first.',
      searchGifs: 'Search GIPHY',
      loadMore: 'Load More',
      retryGifs: 'Try Again',
      moreActions: 'More Actions',
      contactDetails: 'Contact Details',
      contactName: 'Name',
      phoneNumber: 'Phone Number',
      call: 'Call',
      messageAction: 'Message',
      addContact: 'Add Contact',
      deleteContact: 'Delete Contact',
      selectedCount: '{count} Selected',
      deleteSelected: 'Delete',
      contactSaveFailed: 'The contact could not be saved.',
      contactDeleteFailed: 'The contact could not be deleted.',
      callFailed: 'The call could not be started.',
      emoji: 'Emoji',
      voiceMessage: 'Audio Message',
      recordVoice: 'Record Audio',
      playAudio: 'Play Audio',
      pauseAudio: 'Pause Audio',
      recording: 'Recording',
      stopAndSend: 'Stop and Send',
      cancelRecording: 'Cancel Recording',
      sending: 'Sending...',
      delivered: 'Delivered',
      notDelivered: 'Not Delivered',
      microphoneUnavailable: 'The microphone is unavailable.',
      recordingTooLarge: 'The recording is too large.',
      noSim: 'No SIM',
      noSimBody: 'Insert a SIM card in Settings to send and receive messages.',
      noMessages: 'No Messages',
      noMessagesBody: 'Start a conversation with a phone number.',
      noResults: 'No Results',
      today: 'Today',
      yesterday: 'Yesterday',
      errors: {
        invalid_number: 'Enter a valid phone number.',
        invalid_message: 'Enter a message.',
        invalid_voice: 'The audio message is invalid.',
        invalid_attachment: 'The attachment is invalid.',
        media_provider_unconfigured: 'Photo uploads are not configured.',
        capture_provider_unavailable: 'The screenshot resource is unavailable.',
        capture_failed: 'The photo could not be captured.',
        video_provider_unavailable: 'Video capture requires the screencapture resource.',
        video_capture_failed: 'The video could not be recorded.',
        recording_in_progress: 'A video is already being recorded.',
        recording_not_found: 'No active video recording was found.',
        gif_provider_unconfigured: 'GIF search is not configured.',
        gif_provider_unauthorized: 'The configured GIPHY API key is invalid.',
        gif_provider_rate_limited: 'GIPHY is busy. Try again in a moment.',
        gif_provider_failed: 'GIF search is temporarily unavailable.',
        self_message: 'You cannot message your own number.',
        recipient_not_found: 'That number is unavailable.',
        no_sim: 'This phone has no SIM card.',
        rate_limited: 'Too many messages. Try again in a minute.',
        request_failed: 'Messages are temporarily unavailable.',
        default: 'The message could not be sent.',
      },
    },
    appStore: {
      name: 'App Store',
      get: 'GET',
      open: 'OPEN',
      installing: 'Installing',
      searchPlaceholder: 'Search apps and games',
      appsTitle: 'Built-in Apps',
      gamesTitle: 'Games',
      selected: 'Apps available for your Sky Phone',
      tabs: {
        apps: 'Apps',
        games: 'Games',
        search: 'Search',
      },
    },
    phone: {
      name: 'Phone',
      recents: 'Recents',
      contacts: 'Contacts',
      keypad: 'Keypad',
      noSim: 'No SIM',
      noSimBody: 'Insert a SIM card in Settings to make calls.',
      noRecents: 'No Recent Calls',
      noContacts: 'No Contacts',
      searchContacts: 'Search Contacts',
      addContact: 'New Contact',
      editContact: 'Edit Contact',
      contactName: 'Name',
      phoneNumber: 'Phone Number',
      call: 'Call',
      calling: 'calling...',
      incoming: 'Incoming Call',
      incomingDirection: 'Incoming',
      outgoingDirection: 'Outgoing',
      connected: 'Connected',
      missed: 'Missed',
      declined: 'Declined',
      busy: 'Busy',
      unavailable: 'Unavailable',
      noAnswer: 'No Answer',
      cancelled: 'Cancelled',
      disconnected: 'Disconnected',
      sim_removed: 'SIM Removed',
      completed: 'Call Ended',
      answer: 'Answer',
      decline: 'Decline',
      hangup: 'End',
      addToContacts: 'Add to Contacts',
      deleteContact: 'Delete Contact',
      replaceTitle: 'Replace SIM?',
      replaceBody: 'The current SIM will be returned to your inventory.',
      choosePhone: 'Choose a Phone',
      choosePhoneBody: 'Select the phone that should receive {number}.',
      emptyPhone: 'No SIM inserted',
      errors: {
        invalid_contact: 'Enter a name and valid phone number.',
        invalid_number: 'Enter a valid phone number.',
        no_sim: 'This phone has no SIM card.',
        airplane_mode: 'Turn off Airplane Mode to make calls.',
        self_call: 'You cannot call your own number.',
        busy: 'The line is busy.',
        rate_limited: 'Too many calls. Try again in a minute.',
        voice_unavailable: 'The configured phone voice service is unavailable.',
        inventory_full: 'There is no room for the ejected SIM card.',
        operation_in_progress:
          'Another phone operation is already in progress.',
        sim_request_expired:
          'The SIM selection expired. Use the SIM card again.',
        sim_not_owned: 'That SIM card is no longer in your inventory.',
        phone_not_owned: 'That phone is no longer in your inventory.',
        metadata_unsupported:
          'The configured inventory cannot store unique SIM metadata.',
        request_failed: 'The phone request failed.',
        default: 'The phone request failed.',
      },
    },
    banking: {
      name: 'Banking',
      welcome: 'Welcome back',
      totalBalance: 'Total Balance',
      recentPeriod: 'in recent activity',
      actions: 'Banking actions',
      send: 'Send',
      accounts: 'Accounts',
      bankAccount: 'Bank account',
      cash: 'Cash',
      latestTransactions: 'Latest Transactions',
      allTransactions: 'All Transactions',
      viewAll: 'View all',
      noTransactions: 'Your banking activity will appear here.',
      home: 'Home',
      activity: 'Activity',
      chartDaySummary: '{day}: {incoming} incoming, {outgoing} outgoing',
      navigation: 'Banking navigation',
      incoming: 'Incoming',
      outgoing: 'Outgoing',
      refresh: 'Refresh banking data',
      unavailable: 'Banking unavailable',
      tryAgain: 'Try Again',
      playerId: 'Player ID',
      playerIdPlaceholder: 'Enter the recipient ID',
      amount: 'Amount',
      amountPlaceholder: 'Enter an amount',
      transactions: {
        deposit: 'Cash deposit',
        withdrawal: 'Cash withdrawal',
        transfer_in: 'Incoming transfer',
        transfer_out: 'Outgoing transfer',
      },
      forms: {
        transfer: {
          title: 'Send money',
          body: 'Transfer money from your bank account to an online player.',
          submit: 'Send transfer',
        },
      },
      errors: {
        invalid_request: 'Enter a valid whole amount and player ID.',
        insufficient_funds: 'There is not enough money in this account.',
        target_not_found: 'The recipient is not online.',
        self_transfer: 'You cannot send money to yourself.',
        rate_limited: 'Please wait before making another transaction.',
        transfer_failed: 'The transfer could not be completed.',
        banking_unavailable: 'Banking is currently unavailable.',
        request_failed: 'The banking request failed.',
        default: 'The banking request failed.',
      },
    },
    calculator: { name: 'Calculator' },
    snake: {
      name: 'Snake',
      backToMenu: 'Back to game menu',
      board: 'Snake game board',
      controls: 'Direction controls',
      directions: {
        down: 'Move down',
        left: 'Move left',
        right: 'Move right',
        up: 'Move up',
      },
      gameOver: 'Game Over',
      highScore: 'High Score',
      menu: 'Main Menu',
      pause: 'Pause game',
      paused: 'Paused',
      readyBody: 'Collect fruit, grow longer, and stay clear of every wall.',
      readyTitle: 'Ready to play?',
      restart: 'Play Again',
      resume: 'Resume game',
      score: 'Score',
      speed: 'Speed',
      speeds: { fast: 'Fast', normal: 'Normal', relaxed: 'Relaxed' },
      start: 'Start Game',
      swipeHint: 'Use the arrow keys to steer',
    },
    memory: {
      name: 'Memory',
      backToMenu: 'Back to board selection',
      board: 'Memory card board',
      chooseBody: 'Find every matching pair with as few moves as possible.',
      chooseTitle: 'Choose a board',
      completeEyebrow: 'Board cleared',
      completeTitle: 'Great memory!',
      difficulties: { large: 'Expert', medium: 'Classic', small: 'Quick' },
      eyebrow: 'Matching game',
      hiddenCard: 'Hidden card',
      menu: 'Board Selection',
      moves: 'Moves',
      mute: 'Mute game sounds',
      noBest: 'No best score yet',
      playAgain: 'Play Again',
      restart: 'Restart',
      revealedCard: 'Revealed card',
      time: 'Time',
      unmute: 'Turn on game sounds',
    },
    numberMerge: {
      name: '2048',
      backToMenu: 'Back to game menu',
      best: 'Best',
      board: '2048 game board',
      cancel: 'Keep Current Game',
      confirmBody: 'Your current board will be replaced. This cannot be undone.',
      confirmNew: 'Start New Game',
      confirmTitle: 'Replace current game?',
      continueGame: 'Continue Game',
      controls: 'Move controls',
      directions: {
        down: 'Move tiles down',
        left: 'Move tiles left',
        right: 'Move tiles right',
        up: 'Move tiles up',
      },
      eyebrow: 'Number puzzle',
      gameOverBody: 'No empty spaces or matching neighbours remain.',
      gameOverTitle: 'No more moves',
      highest: 'Highest Tile',
      howToBody: 'Swipe to combine matching tiles. Each pair becomes one larger tile.',
      howToExample: '2 + 2 = 4  ·  4 + 4 = 8  ·  …  ·  1024 + 1024 = 2048',
      howToGoal: 'Goal: create the 2048 tile before the board has no moves left.',
      howToTitle: 'How to play',
      keepPlaying: 'Keep Playing',
      mainMenu: 'Main Menu',
      menuBody: 'Slide matching numbers together and work your way up to 2048.',
      menuTitle: 'Build the 2048 tile',
      mute: 'Mute game sounds',
      newGame: 'New Game',
      score: 'Score',
      swipeHint: 'Swipe the board or use arrow keys / WASD',
      unmute: 'Turn on game sounds',
      wonBody: 'You reached the legendary tile. Continue climbing or begin again.',
      wonTitle: 'You made 2048!',
    },
    minesweeper: {
      name: 'Minesweeper',
      backToMenu: 'Back to game menu',
      board: 'Minesweeper board',
      chooseBody:
        'Reveal every safe field. Numbers show how many mines touch that field.',
      chooseTitle: 'Choose a minefield',
      difficulties: { classic: 'Classic', expert: 'Expert', quick: 'Quick' },
      emptyCell: 'Revealed empty field',
      eyebrow: 'Mine puzzle',
      flaggedCell: 'Flagged field',
      gameHint: 'Tap to reveal · Hold or right-click to flag',
      hiddenCell: 'Hidden field',
      longPressHint: 'Tap to reveal · Hold to place a flag',
      lostBody:
        'A mine was hidden there. Mark suspicious fields and try again.',
      lostTitle: 'Mine triggered',
      mainMenu: 'Main Menu',
      mineCell: 'Revealed mine',
      mines: 'Mines',
      mute: 'Mute game sounds',
      noBest: 'No best time yet',
      numberCell: 'Revealed field with {count} adjacent mines',
      playAgain: 'Play Again',
      restart: 'Restart',
      resume: 'Continue Game',
      time: 'Time',
      unmute: 'Turn on game sounds',
      wonBody: 'Minefield cleared in {time}.',
      wonTitle: 'Field cleared!',
    },
    towerStack: {
      name: 'Tower Stack',
      backToMenu: 'Back to game menu',
      bestHeight: 'Best Height',
      bestScore: 'Best Score',
      blocks: 'Blocks',
      eyebrow: 'Timing challenge',
      finalScore: 'Final Score',
      gameHint: 'Tap the playfield to place the moving block',
      gameOver: 'Tower collapsed',
      height: 'Height',
      mainMenu: 'Main Menu',
      menuBody:
        'Stop each moving block above the tower. Only the overlapping part remains.',
      menuTitle: 'How high can you build?',
      mute: 'Mute game sounds',
      newGame: 'Start New Game',
      pause: 'Pause or resume game',
      paused: 'Paused',
      perfect: 'PERFECT!',
      placeBlock: 'Place moving block',
      playAgain: 'Play Again',
      ready: 'Ready to stack?',
      resume: 'Continue Game',
      score: 'Score',
      start: 'Start Game',
      tapHint: 'Every tap places one block · perfect hits earn bonus points',
      unmute: 'Turn on game sounds',
    },
    skyFlappy: {
      name: 'Sky Flappy',
      backToMenu: 'Back to game menu',
      best: 'Best',
      design: 'Sky design',
      designs: { dawn: 'Dawn', neon: 'Neon', storm: 'Storm' },
      eyebrow: 'Sky challenge',
      firstTap: 'Tap to launch',
      flap: 'Fly upward',
      gameHint: 'Tap anywhere in the sky to fly upward',
      gameOver: 'Flight ended',
      highScore: 'High Score',
      mainMenu: 'Main Menu',
      menuBody:
        'Guide the sky bird through every tower opening without touching the edges.',
      menuTitle: 'Fly between the towers',
      mute: 'Mute game sounds',
      pause: 'Pause or resume game',
      paused: 'Paused',
      playAgain: 'Fly Again',
      points: 'Points',
      ready: 'Ready for takeoff?',
      resume: 'Continue Flight',
      score: 'Score',
      start: 'Start Flight',
      tapHint: 'Tap to flap · gravity pulls the bird down',
      unmute: 'Turn on game sounds',
    },
    neonDrop: {
      name: 'Neon Drop',
      backToMenu: 'Back to game menu',
      bestLines: 'Best Lines',
      bestScore: 'Best Score',
      board: 'Neon Drop reactor board',
      controls: 'Block controls',
      eyebrow: 'Energy block puzzle',
      gameHint: 'Swipe the reactor or use the five controls',
      gameOver: 'Reactor overloaded',
      hardDrop: 'Drop block immediately',
      howToBody:
        'Fill complete horizontal rows to dissolve them and keep the reactor clear.',
      howToTitle: 'Charge the reactor',
      left: 'Move block left',
      level: 'Level',
      lines: 'Lines',
      mainMenu: 'Main Menu',
      menuBody:
        'Rotate and place falling energy shapes. Every cleared row increases your charge.',
      menuTitle: 'Build complete energy lines',
      mute: 'Mute game sounds',
      newGame: 'Start New Game',
      next: 'Next',
      pause: 'Pause or resume game',
      paused: 'Paused',
      playAgain: 'Play Again',
      points: 'Points',
      ready: 'Reactor ready',
      resume: 'Continue Game',
      right: 'Move block right',
      rotate: 'Rotate block',
      score: 'Score',
      softDrop: 'Move block down',
      start: 'Start Reactor',
      unmute: 'Turn on game sounds',
    },
    citymarkt: {
      name: 'CityMarkt',
      eyebrow: 'Los Santos marketplace',
      tabs: { discover: 'Discover', search: 'Search', sell: 'Sell', inbox: 'Inbox', profile: 'Me' },
      categories: {
        vehicles: 'Vehicles', property: 'Property', electronics: 'Electronics', clothing: 'Clothing',
        tools: 'Tools', leisure: 'Leisure', services: 'Services', jobs: 'Jobs', wanted: 'Wanted', other: 'Other',
      },
      conditions: { new: 'New', very_good: 'Very good', used: 'Used', defective: 'Defective' },
      priceTypes: { fixed: 'Fixed price', negotiable: 'Negotiable', free: 'Free' },
      offerStatus: {
        pending: 'Open',
        accepted: 'Accepted',
        declined: 'Declined',
        countered: 'Negotiated',
      },
      districts: {
        los_santos: 'Los Santos', vinewood: 'Vinewood', vespucci: 'Vespucci',
        south_los_santos: 'South Los Santos', sandy_shores: 'Sandy Shores',
        paleto_bay: 'Paleto Bay', blaine_county: 'Blaine County',
      },
      status: { active: 'Active', reserved: 'Reserved', sold: 'Sold', expired: 'Expired', removed: 'Removed' },
      reportReasons: { prohibited: 'Prohibited item', fraud: 'Suspected fraud', spam: 'Spam', offensive: 'Offensive content', other: 'Other' },
      searchPlaceholder: 'What are you looking for?', allCategories: 'All categories', allDistricts: 'All districts',
      sortNewest: 'Newest first', sortPriceAsc: 'Lowest price', sortPriceDesc: 'Highest price',
      freshOffers: 'Fresh offers', offers: 'offers', noListings: 'No offers found',
      noListingsBody: 'Try another search or category.', noDistrict: 'No district',
      free: 'Free', negotiablePrice: '${price} negotiable', money: '${price}',
      hoursAgo: '{count}h ago', daysAgo: '{count}d ago', photos: 'photos', activeListings: 'active listings',
      signInTitle: 'Sign in to iFruit', signInBody: 'Use Settings to sign in before selling, saving or messaging.',
      noMessages: 'No conversations', noMessagesBody: 'Messages about offers will appear here.',
      myListings: 'My listings', favorites: 'Favorites', noProfileListings: 'Nothing here yet',
      phone: 'Phone', contactSeller: 'Contact seller', messagePlaceholder: 'Hi, is this still available?',
      signInToMessage: 'Sign in to your iFruit account to send a message.',
      edit: 'Edit', makeActive: 'Make active', markSold: 'Mark sold', remove: 'Remove',
      createListing: 'Create listing', editListing: 'Edit listing', save: 'Save', step: 'Step {current} of {total}', next: 'Next', previous: 'Back', publish: 'Publish',
      addPhotos: 'Add photos', addPhotosBody: 'Choose up to six photos from your gallery or take new ones. Photos are optional and the first becomes the cover.',
      chooseGallery: 'Choose from gallery', chooseGalleryBody: 'Use photos saved on this phone.',
      takePhotos: 'Take photos', takePhotosBody: 'Take several new photos for this listing.',
      selectedPhotos: 'Selected photos', gallery: 'Gallery', camera: 'Camera', takePhoto: 'Take photo',
      cameraHint: 'Take as many photos as you need. Each shot is added to the listing automatically.',
      noPhoto: 'No photo available', noPhotoBody: 'The seller did not add a photo to this listing.',
      noPhotoOptional: 'You can publish without a photo or add one from the gallery or camera.',
      previousPhoto: 'Previous photo', nextPhoto: 'Next photo', photo: 'Photo',
      removePhoto: 'Remove photo {number}', photoLimit: 'You can add up to six photos.',
      describeOffer: 'Describe your offer', title: 'Title', description: 'Description', category: 'Category', condition: 'Condition',
      characterCount: '{current} / {maximum} characters', minimumCharacters: 'min. {minimum}',
      priceAndPlace: 'Price and location', priceType: 'Price type', price: 'Price', district: 'District',
      showPhone: 'Show my current phone number', preview: 'Preview', published: 'Your listing is live.',
      writeMessage: 'Write a message', reserveForBuyer: 'Reserve for this buyer', statusChanged: 'Listing updated.',
      offer: 'Offer', counterOffer: 'Counteroffer', makeOffer: 'Make an offer',
      offeredByYou: 'You sent this offer.', offeredToYou: 'You received this offer.',
      acceptOffer: 'Accept', declineOffer: 'Decline', negotiateOffer: 'Counter',
      offerFor: 'Your offer for', offerAmount: 'Offer amount', sendOffer: 'Send offer',
      offerSent: 'Your offer was sent.', offerAccepted: 'Offer accepted.', offerDeclined: 'Offer declined.',
      reportListing: 'Report listing', reportWhy: 'Why are you reporting this?', reportDetails: 'Add details (optional)',
      sendReport: 'Send report', blockSeller: 'Block seller', reported: 'Report sent.', blocked: 'Seller blocked.',
      newMessage: 'New CityMarkt message from {sender}',
      newOffer: '{sender} offered ${price}.',
      offerAcceptedNotification: '{sender} accepted your ${price} offer.',
      offerRejectedNotification: '{sender} declined your ${price} offer.',
      errors: {
        invalid_listing: 'Check the listing details.', invalid_price: 'Enter a valid price.',
        invalid_images: 'Choose valid photos from this phone.', phone_unavailable: 'Insert a SIM or hide your number.',
        listing_limit: 'You have reached the active listing limit.', listing_not_found: 'This listing is no longer available.',
        invalid_message: 'Enter a valid message.', inquiry_not_found: 'This conversation is no longer available.',
        invalid_offer: 'Enter a valid whole-number offer.', invalid_offer_response: 'This offer action is invalid.',
        offer_listing_unavailable: 'This listing is no longer available for offers.',
        offer_closed: 'This negotiation is already complete.', offer_waiting: 'Wait for the other person to respond.',
        offer_not_allowed: 'You cannot make an offer right now.', offer_not_actionable: 'This offer can no longer be changed.',
        offer_conflict: 'The offer changed. Please try again.',
        blocked: 'Messages are blocked between these accounts.', already_reported: 'You already reported this listing.',
        rate_limited: 'Too many requests. Try again shortly.', not_authenticated: 'Sign in to your iFruit account first.',
        conflict: 'The listing changed. Open it again.', request_failed: 'CityMarkt is temporarily unavailable.',
        default: 'The CityMarkt request failed.',
      },
    },
    localPages: {
      name: 'Local Pages', eyebrow: 'Your city. Your stories.', cityPulse: 'Live from Los Santos',
      heroTitle: 'What is happening nearby?', heroBody: 'Discover places, people and local tips.',
      searchPlaceholder: 'Search posts and places', search: 'Search', allCategories: 'All categories',
      allLosSantos: 'Los Santos', hoursAgo: '{count}h ago', daysAgo: '{count}d ago',
      categories: { recommendation: 'Recommended', wanted: 'Wanted', service: 'Service', event: 'Event', place: 'Place', community: 'Community', citymarkt: 'CityMarkt' },
      discover: 'Discover', create: 'Post', profile: 'Profile', post: 'Post', posts: 'posts',
      noPosts: 'Nothing here yet', noPostsBody: 'Be the first to share something with the city.', noPhoto: 'No photo attached',
      signInTitle: 'Your Local Pages profile', signInBody: 'Sign in to iFruit in Settings to publish and save posts.',
      localCreator: 'Local creator', myPosts: 'My posts', saved: 'Saved', save: 'Save', likes: 'likes',
      location: 'Location', sharedFrom: 'Shared from CityMarkt', openCityMarkt: 'Open CityMarkt listing',
      newPost: 'New local post', shareWithCity: 'Share with the city', publish: 'Publish', published: 'Your post is live.', deleted: 'Post deleted.',
      title: 'Title', body: 'Your story', category: 'Category', titlePlaceholder: 'What should people know?', bodyPlaceholder: 'Add details, a recommendation or directions...',
      photos: 'Photos', optional: 'optional', camera: 'Camera', gallery: 'Gallery', photoLimit: 'You can add up to six photos.',
      cityMarktShare: 'Share to Local Pages', cityMarktShared: 'Shared to Local Pages.', cityMarktShareHint: 'One CityMarkt share per day',
      errors: { invalid_post: 'Add a title and a little more detail.', invalid_images: 'Choose valid photos from this phone.', invalid_request: 'This action is not valid.', post_not_found: 'This post is no longer available.', citymarkt_not_found: 'This CityMarkt listing is unavailable.', citymarkt_daily_limit: 'You already shared a CityMarkt listing today.', citymarkt_already_shared: 'This listing was already shared.', not_authenticated: 'Sign in to iFruit first.', rate_limited: 'Too many requests. Try again shortly.', request_failed: 'The post could not be saved.', default: 'Local Pages is temporarily unavailable.' },
    },
    map: {
      name: 'Map',
      controls: 'Map controls',
      currentLocation: 'Current Location',
      imageError: 'The map image could not be loaded.',
      switchStyle: 'Switch Map Type',
      styles: {
        default: 'Default Map',
        satellite: 'Satellite Map',
        atlas: 'Atlas Map',
        roads: 'Road Map',
      },
    },
    weather: {
      name: 'Weather',
      now: 'Now',
      today: 'Today',
      refresh: 'Refresh weather',
      details: 'Weather details',
      feelsLike: 'Feels Like',
      wind: 'Wind',
      humidity: 'Humidity',
      rain: 'Precipitation',
      hourly: '24-Hour Forecast',
      daily: '7-Day Forecast',
      unavailable: 'Weather is unavailable',
      stale: 'Showing the last available weather update.',
      tryAgain: 'Try Again',
      regions: {
        los_santos: 'Los Santos',
        blaine_county: 'Blaine County',
        cayo_perico: 'Cayo Perico',
      },
      conditions: {
        sunny: 'Sunny',
        clear: 'Clear',
        partly_cloudy: 'Partly Cloudy',
        cloudy: 'Cloudy',
        rain: 'Rain',
        thunder: 'Thunderstorms',
        fog: 'Foggy',
        snow: 'Snow',
      },
      summaries: {
        sunny: 'Bright skies throughout the current period.',
        clear: 'Calm and clear conditions across the region.',
        partly_cloudy: 'Sunshine mixed with passing clouds.',
        cloudy: 'Cloud cover will remain over the region.',
        rain: 'Wet conditions are moving through the area.',
        thunder: 'Stormy conditions with heavy bursts of rain.',
        fog: 'Reduced visibility in low-lying areas.',
        snow: 'Cold conditions with snow across the region.',
      },
    },
    camera: {
      name: 'Camera',
      flash: 'Flash',
      flip: 'Flip camera',
      landscape: 'Switch to landscape',
      portrait: 'Switch to portrait',
      photo: 'Photo',
      video: 'Video',
      focusHelp: 'Space for movement',
      returnHelp: 'Space to return',
      uploading: '{count} uploading',
      saving: 'Saving video...',
      openGallery: 'Open Gallery',
      takePhoto: 'Take photo',
      startRecording: 'Start recording',
      stopRecording: 'Stop recording',
      saved: 'Saved to Gallery.',
      zoom: 'Set camera zoom to {zoom}',
      errors: {
        cancelled: 'Capture cancelled.',
        capture_failed: 'Unable to capture the game view.',
        invalid_media_type: 'The uploaded media type is invalid.',
        invalid_upload: 'The upload could not be verified.',
        invalid_upload_token: 'The upload session is no longer valid.',
        missing_config: 'Camera uploads are not configured.',
        not_found: 'The media item no longer exists.',
        operation_in_progress:
          'Another media operation is already in progress.',
        owner_changed: 'The active phone account changed during upload.',
        rate_limited: 'Too many media actions. Try again shortly.',
        request_failed: 'The camera request failed.',
        request_timeout: 'The media service timed out.',
        unsupported: 'Video recording is not supported.',
        upload_failed: 'The media upload failed.',
        upload_timeout: 'The media upload timed out.',
      },
    },
    calendar: {
      name: 'Calendar',
      today: 'Today',
      calendars: 'Calendars',
      calendar: 'Calendar',
      calendarName: 'iFruit',
      searchPlaceholder: 'Search events',
      previousMonth: 'Previous month',
      nextMonth: 'Next month',
      eyebrow: 'Your time. Clearly planned.',
      schedule: 'Schedule',
      event: 'Event',
      appointment: 'Appointment',
      newEvent: 'New event',
      editEvent: 'Edit event',
      deleteEvent: 'Delete event',
      details: 'Event details',
      title: 'Title',
      titlePlaceholder: 'What is planned?',
      date: 'Date',
      starts: 'Starts',
      ends: 'Ends',
      reminder: 'Reminder',
      note: 'Note',
      notePlaceholder: 'Add details, an address or anything to remember...',
      noEvents: 'Nothing planned',
      noEventsBody: 'This day is still free. Add an event whenever you are ready.',
      signInTitle: 'Your iFruit calendar',
      signInBody: 'Sign in to iFruit in Settings to sync appointments and receive reminders.',
      reminderNotification: 'Upcoming: {title}',
      views: {
        compact: 'Compact',
        stacked: 'Stacked',
        details: 'Details',
        list: 'List',
      },
      reminders: {
        none: 'No reminder',
        atStart: 'At start time',
        tenMinutes: '10 minutes before',
        thirtyMinutes: '30 minutes before',
        oneHour: '1 hour before',
        oneDay: '1 day before',
      },
      errors: {
        invalid_event: 'Enter a title and make sure the end is after the start.',
        invalid_range: 'This calendar period is invalid.',
        conflict: 'This event changed on another phone. Open it again.',
        rate_limited: 'Too many changes. Try again shortly.',
        not_authenticated: 'Sign in to your iFruit account first.',
        request_failed: 'Calendar is temporarily unavailable.',
        default: 'The calendar request failed.',
      },
    },
    clock: {
      name: 'Clock',
      lap: 'Lap',
      minutes: 'Minutes',
      add: 'Add alarm',
      location: 'Los Santos',
      tabs: {
        world: 'Clock',
        alarm: 'Alarm',
        stopwatch: 'Stopwatch',
        timer: 'Timer',
      },
      alarm: {
        add: 'Add Alarm',
        edit: 'Edit Alarm',
        ringing: 'Alarm',
        time: 'Time',
        hours: 'Hours',
        repeat: 'Repeat',
        note: 'Note',
        notePlaceholder: 'Alarm',
        sound: 'Sound',
        delete: 'Delete Alarm',
        never: 'Never',
        everyDay: 'Every Day',
        weekdays: 'Weekdays',
        weekends: 'Weekends',
        days: {
          sunday: 'Sunday',
          monday: 'Monday',
          tuesday: 'Tuesday',
          wednesday: 'Wednesday',
          thursday: 'Thursday',
          friday: 'Friday',
          saturday: 'Saturday',
        },
        daysShort: {
          sunday: 'Sun',
          monday: 'Mon',
          tuesday: 'Tue',
          wednesday: 'Wed',
          thursday: 'Thu',
          friday: 'Fri',
          saturday: 'Sat',
        },
        sounds: {
          radar: 'Radar',
          beacon: 'Beacon',
          chimes: 'Chimes',
          apex: 'Apex',
          aurora: 'Aurora',
          circuit: 'Circuit',
          constellation: 'Constellation',
          daybreak: 'Daybreak',
          uplift: 'Uplift',
        },
      },
      timer: {
        time: 'Timer duration',
        hours: 'Hours',
        hoursShort: 'hr',
        minutes: 'Minutes',
        minutesShort: 'min',
        seconds: 'Seconds',
        secondsShort: 'sec',
        note: 'Note',
        notePlaceholder: 'Timer',
        sound: 'Sound',
        ringing: 'Timer',
      },
    },
    mail: {
      name: 'Mail',
      login: 'Sign In',
      register: 'Create Account',
      registerLink: 'Register',
      loginTitle: 'iFruit Mail',
      loginBody: 'Sign in to use your shared iFruit mailbox.',
      registerBody: 'Choose your new @ifruit.com address.',
      localPart: 'Email address',
      email: 'Email',
      password: 'Password',
      confirmPassword: 'Confirm password',
      accountEyebrow: 'Your iFruit account',
      emailPlaceholder: 'name@ifruit.com',
      passwordPlaceholder: 'Enter password',
      passwordWarning:
        'Use an in-character password. Do not reuse a real-world password.',
      mailboxes: 'Mailboxes',
      inbox: 'Inbox',
      sent: 'Sent',
      drafts: 'Drafts',
      trash: 'Trash',
      compose: 'New Message',
      recipients: 'To',
      recipientHint: 'Separate up to 10 addresses with commas.',
      recipientPlaceholder: 'name@ifruit.com',
      subject: 'Subject',
      subjectPlaceholder: "What's this about?",
      body: 'Message',
      messagePlaceholder: 'Write a message...',
      search: 'Search mail',
      messages: 'messages',
      noMessageContent: 'No message content',
      noMail: 'No Mail',
      noMailBody: 'Messages in this mailbox will appear here.',
      noResults: 'No Results',
      noResultsBody: 'Try a different search.',
      untitled: '(no subject)',
      loadMore: 'Load More',
      logout: 'Sign Out',
      from: 'From',
      to: 'To',
      reply: 'Reply',
      replyAll: 'Reply All',
      forward: 'Forward',
      formatBold: 'Bold',
      formatItalic: 'Italic',
      formatBulletList: 'Bullet list',
      formatNumberedList: 'Numbered list',
      formatQuote: 'Quote',
      undo: 'Undo',
      redo: 'Redo',
      markRead: 'Read',
      markUnread: 'Mark as Unread',
      delete: 'Delete',
      moveToTrash: 'Move to Trash',
      restore: 'Restore',
      deleteForever: 'Delete Forever',
      emptyTrash: 'Empty Trash',
      emptyTrashTitle: 'Empty Trash?',
      emptyTrashBody: 'Every message in Trash will be permanently deleted.',
      deleteDraft: 'Delete Draft',
      sentSuccess: 'Message sent.',
      newMessage: 'New mail from {sender}',
      passwordsMismatch: 'Passwords do not match.',
      errors: {
        invalid_email: 'Choose a valid 3–32 character iFruit address.',
        invalid_password: 'Password must be 6–64 characters.',
        invalid_credentials: 'Email or password is incorrect.',
        email_taken: 'That iFruit address is already registered.',
        rate_limited: 'Too many attempts. Try again in a minute.',
        invalid_message: 'Add a valid recipient and a subject or message.',
        recipient_not_found: 'One or more recipients do not exist.',
        invalid_draft: 'This draft contains invalid content.',
        not_authenticated: 'Your mail session has ended. Sign in again.',
        request_failed: 'Mail is temporarily unavailable.',
        invalid_request: 'The mail request was invalid.',
        default: 'The mail request failed.',
      },
    },
    notes: {
      name: 'Notes',
      note: 'Note',
      back: 'Back',
      actions: 'Note actions',
      newNote: 'New Note',
      searchPlaceholder: 'Search Notes',
      title: 'Title',
      titlePlaceholder: 'Note title',
      body: 'Note',
      bodyPlaceholder: 'Start writing...',
      untitled: 'Untitled',
      noText: 'No additional text',
      emptyBadge: 'ON DEVICE',
      emptyTitle: 'No Notes',
      emptyBody: 'Create a note to keep important details close at hand.',
      noResults: 'No Results',
      noResultsBody: 'Try searching for a different word or phrase.',
      pin: 'Pin note',
      unpin: 'Unpin note',
      deleteNote: 'Delete note',
    },
    photos: {
      name: 'Gallery',
      count: '{count} items',
      loading: 'Loading Gallery...',
      emptyTitle: 'No Photos or Videos',
      emptyBody: 'Captures from Camera will appear here.',
      photo: 'Photo',
      video: 'Video',
      photoAlt: 'Gallery photo',
      videoAlt: 'Gallery video',
      delete: 'Delete media',
      deleteTitle: 'Delete Media?',
      deleteBody: 'This photo or video will be permanently deleted.',
      deleted: 'Media deleted.',
      zoomIn: 'Zoom in',
      zoomOut: 'Zoom out',
      resetZoom: 'Reset zoom',
      filters: { all: 'All', photos: 'Photos', videos: 'Videos' },
      errors: {
        cancelled: 'The media action was cancelled.',
        capture_failed: 'Unable to capture the game view.',
        invalid_media_type: 'The media type is invalid.',
        invalid_upload: 'The upload could not be verified.',
        invalid_upload_token: 'The upload session is no longer valid.',
        missing_config: 'Gallery uploads are not configured.',
        not_found: 'The media item no longer exists.',
        operation_in_progress:
          'Another media operation is already in progress.',
        owner_changed: 'The active phone account changed.',
        rate_limited: 'Too many media actions. Try again shortly.',
        request_failed: 'The Gallery request failed.',
        request_timeout: 'The media service timed out.',
        unsupported: 'This media format is not supported.',
        upload_failed: 'The media upload failed.',
        upload_timeout: 'The media upload timed out.',
      },
    },
    settings: {
      name: 'Settings',
      searchPlaceholder: 'Search',
      airplaneMode: 'Airplane Mode',
      streamerMode: 'Streamer Mode',
      wallpaper: 'Wallpaper',
      on: 'On',
      off: 'Off',
      accountName: 'iFruit Account',
      accountDetail: 'Important Data & Cloud',
      accountLocalDetail: 'Not signed in',
      accountCloudDetail: 'Important data syncs through iFruit Cloud',
      accountLoginBody:
        'Sign in to keep your important data safe. Without an iFruit Account, it will be lost with the phone.',
      accountInformation: 'Account Information',
      accountStatus: 'Account Status',
      accountStatusValue: 'Active',
      accountStorage: 'Cloud Storage',
      accountStorageValue: 'On Device',
      accountPurchases: 'Media & Purchases',
      accountPurchasesValue: 'Available',
      notifications: 'Notifications',
      sounds: 'Sounds & Haptics',
      general: 'General Settings',
      appearance: 'Appearance',
      allowNotifications: 'Allow Notifications',
      notificationSounds: 'Sounds',
      notificationDuration: 'Notification Duration',
      seconds: '{seconds} seconds',
      ringtoneVolume: 'Ringtone Volume',
      notificationVolume: 'Notification Volume',
      ringtone: 'Ringtone',
      notificationSound: 'Notification Sound',
      appearanceMode: 'Appearance Mode',
      automatic: 'Automatic',
      light: 'Light',
      dark: 'Dark',
      phoneScale: 'Phone Scale',
      phoneFrame: 'Phone Frame',
      about: 'About',
      deviceName: 'Device Name',
      deviceNameValue: 'Sky Phone',
      softwareVersion: 'Software Version',
      language: 'Language',
      languageValue: 'English',
      localStorage: 'Local Storage',
      localStorageValue: 'On Device',
      deviceInformation: 'Device Information',
      imei: 'IMEI',
      simCard: 'SIM Card',
      simNumber: 'Phone Number',
      simType: 'SIM Type',
      registeredSim: 'Registered',
      anonymousSim: 'Anonymous',
      noSim: 'No SIM',
      ejectSim: 'Eject SIM',
      ejectSimBody: 'Return this SIM card to your inventory?',
      linkedDevices: 'Linked Devices',
      thisDevice: 'This Phone',
      removeDevice: 'Remove Device',
      removeDeviceBody:
        'Enter your iFruit password to remove this device from the account.',
      signOut: 'Sign Out',
      factoryReset: 'Erase All Content and Settings',
      factoryResetBody:
        'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
      factoryResetProgress: 'Erasing iFruit Phone',
      factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
      accountErrors: {
        invalid_email: 'Choose a valid 3–32 character iFruit address.',
        invalid_password: 'Password must be 6–64 characters.',
        invalid_credentials: 'Email or password is incorrect.',
        email_taken: 'That iFruit address is already registered.',
        rate_limited: 'Too many attempts. Try again in a minute.',
        current_device: 'Sign out instead of removing the current phone.',
        device_not_found: 'That device is no longer linked.',
        default: 'The account request failed.',
      },
      back: 'Settings',
      wallpaperPicker: 'Built-in Wallpapers',
      toggle: {
        airplaneMode: 'Toggle Airplane Mode',
        streamerMode: 'Toggle Streamer Mode',
        notifications: 'Toggle notifications for {app}',
        notificationSounds: 'Toggle notification sounds for {app}',
      },
      frames: {
        black: 'Black',
        blue: 'Blue',
        green: 'Green',
        lavender: 'Lavender',
        white: 'White',
      },
      ringtones: {
        skyline: 'Skyline',
        horizon: 'Horizon',
        pulse: 'Pulse',
      },
      notificationSoundsList: {
        chime: 'Chime',
        signal: 'Signal',
        soft: 'Soft',
      },
      wallpapers: {
        midnight: 'Midnight wallpaper',
        aurora: 'Aurora wallpaper',
        ember: 'Ember wallpaper',
      },
    },
  },
  Common: {
    add: 'Add',
    cancel: 'Cancel',
    clear: 'Clear',
    close: 'Close',
    back: 'Back',
    delete: 'Delete',
    done: 'Done',
    edit: 'Edit',
    home: 'Home',
    pause: 'Pause',
    phone: 'Phone',
    phoneStatus: 'Phone status',
    reset: 'Reset',
    loading: 'Loading',
    search: 'Search',
    save: 'Save',
    send: 'Send',
    start: 'Start',
    stop: 'Stop',
  },
  Notifications: { now: 'now' },
  LockScreen: {
    label: 'Lock Screen',
    flashlight: 'Flashlight',
    camera: 'Camera',
    swipeUp: 'Swipe up to open',
  },
  Home: {
    appLibrary: 'App Library',
    appLibrarySearch: 'Search apps',
    allApps: 'All Apps',
    apps: 'Apps',
    dock: 'Dock',
    noApps: 'No apps found',
    removeApp: 'Remove {app} from Home Screen',
    page: 'Page',
    pages: 'Home screen pages',
    groups: {
      suggestions: 'Suggestions',
      recentlyAdded: 'Recently Added',
      games: 'Games',
      productivity: 'Productivity',
      shopping: 'Shopping',
      social: 'Social Networks',
      utilities: 'Utilities',
    },
    widgets: {
      label: 'Widgets',
      weather: { city: 'Los Santos', condition: 'Partly Cloudy' },
      calendar: { event: 'No more events today' },
      battery: { label: 'Phone' },
      media: {
        title: 'Night Drive',
        artist: 'Sky Radio',
        play: 'Play',
        pause: 'Pause',
      },
    },
  },
}

function getByPath(source: LocaleTree, path: string): unknown {
  return path.split('.').reduce<unknown>((current, segment) => {
    if (
      typeof current !== 'object' ||
      current === null ||
      Array.isArray(current)
    )
      return undefined
    return (current as LocaleTree)[segment]
  }, source)
}

export const usePhoneStore = defineStore('phone', {
  state: () => ({
    cameraLandscape: false,
    currentPage: 1,
    device: null as PhoneDevice | null,
    deviceRevisions: {} as Record<string, number>,
    isOpen: false,
    lang: 'en',
    launchOrigin: null as AppLaunchOrigin | null,
    locales: defaultLocales,
    preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
    systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches,
  }),
  getters: {
    isDarkMode(state): boolean {
      if (state.preferences.settings.appearanceMode === 'dark') return true
      if (state.preferences.settings.appearanceMode === 'light') return false
      return state.systemDarkMode
    },
  },
  actions: {
    close(): void {
      this.cameraLandscape = false
      this.isOpen = false
    },
    open(payload: PhoneOpenPayload = {}): void {
      this.lang = payload.lang ?? 'en'
      this.locales = payload.locales ?? defaultLocales
      if (payload.device) this.hydrateDevice(payload.device)
      this.isOpen = true
    },
    hydrateDevice(device: PhoneDevice): void {
      this.device = device
      this.deviceRevisions = Object.fromEntries(
        Object.entries(device.data).map(([key, value]) => [
          key,
          value?.revision ?? 0,
        ]),
      )
      this.preferences = parsePhonePreferences(
        JSON.stringify(device.data.settings?.payload ?? null),
      )
    },
    saveDeviceNamespace(namespace: string, payload: unknown): void {
      const previous = namespaceQueues.get(namespace) ?? Promise.resolve()
      const queued = previous.then(async () => {
        const response = await nuiCall<{ revision: number }>('device:save', {
          namespace,
          payload,
          revision: this.deviceRevisions[namespace] ?? 0,
        })
        if (response.success && response.data) {
          this.deviceRevisions[namespace] = response.data.revision
        }
      })
      const tracked = queued.finally(() => {
        if (namespaceQueues.get(namespace) === tracked)
          namespaceQueues.delete(namespace)
      })
      namespaceQueues.set(namespace, tracked)
    },
    setCurrentPage(page: number, pageCount?: number): void {
      this.currentPage = clampPage(page, pageCount)
    },
    setCameraLandscape(landscape: boolean): void {
      this.cameraLandscape = landscape
    },
    setLaunchOrigin(origin: AppLaunchOrigin | null): void {
      this.launchOrigin = origin
    },
    setAppNotification(
      appId: LaunchablePhoneAppId,
      key: keyof AppNotificationPreferences,
      value: boolean,
    ): void {
      this.preferences.settings.notifications[appId][key] = value
      this.saveDeviceNamespace('settings', this.preferences)
    },
    setPreference<K extends keyof PhonePreferencesV1['settings']>(
      key: K,
      value: PhonePreferencesV1['settings'][K],
    ): void {
      this.preferences.settings[key] = value
      this.saveDeviceNamespace('settings', this.preferences)
    },
    setSystemDarkMode(value: boolean): void {
      this.systemDarkMode = value
    },
    setWallpaper(wallpaper: WallpaperId): void {
      this.preferences.settings.wallpaper = wallpaper
      this.saveDeviceNamespace('settings', this.preferences)
    },
    t(path: string, replacements: Record<string, string> = {}): string {
      const translated = getByPath(this.locales, path)
      const fallback = getByPath(defaultLocales, path)
      const value =
        typeof translated === 'string'
          ? translated
          : typeof fallback === 'string'
            ? fallback
            : path
      return Object.entries(replacements).reduce(
        (result, [key, replacement]) =>
          result.split(`{${key}}`).join(replacement),
        value,
      )
    },
  },
})
