import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
import {
  type GameContentExperienceConfigOverride,
  type GameControlDisplayContentOverride,
} from '../game/core/gameDefinition'
import {
  mergeGameHapticsConfig,
  mergeGameUiEffectsConfig,
  type FeedbackCueKey,
  type GameHapticsConfig,
  type GameHapticsConfigOverrides,
  type GameUiEffectsConfig,
  type GameUiEffectsConfigOverrides,
  type PartialHapticCueConfig,
  type PartialUiCueConfig,
} from '../game/feedback/feedbackConfig'
import {
  DEFAULT_COURSE_STYLE_CONFIG,
  type ControlPointStyleEntry,
  type ControlPointStyleId,
  type CourseLegStyleEntry,
  type CourseLegStyleId,
  type CourseStyleConfig,
  type ScoreBandStyleEntry,
} from '../game/presentation/courseStyleConfig'
import {
  DEFAULT_TRACK_VISUALIZATION_CONFIG,
  TRACK_COLOR_PRESET_MAP,
  TRACK_TAIL_LENGTH_METERS,
  type TrackColorPreset,
  type TrackDisplayMode,
  type TrackTailLengthPreset,
  type TrackStyleProfile,
  type TrackVisualizationConfig,
} from '../game/presentation/trackStyleConfig'
import {
  DEFAULT_GPS_MARKER_STYLE_CONFIG,
  GPS_MARKER_COLOR_PRESET_MAP,
  type GpsMarkerAnimationProfile,
  type GpsMarkerColorPreset,
  type GpsMarkerSizePreset,
  type GpsMarkerStyleConfig,
  type GpsMarkerStyleId,
} from '../game/presentation/gpsMarkerStyleConfig'
import {
  getDefaultSkipRadiusMeters,
  getGameModeDefaults,
  resolveDefaultControlScore,
} from '../game/core/gameModeDefaults'
import {
  type SystemSettingsConfig,
  type SettingLockKey,
  type StoredUserSettings,
} from '../game/core/systemSettingsState'

export interface TileZoomBounds {
  minX: number
  maxX: number
  minY: number
  maxY: number
}

export interface RemoteMapConfig {
  configTitle: string
  configAppId: string
  configSchemaVersion: string
  configVersion: string
  tileSource: string
  minZoom: number
  maxZoom: number
  defaultZoom: number
  initialCenterTileX: number
  initialCenterTileY: number
  projection: string
  projectionModeText: string
  magneticDeclinationDeg: number
  magneticDeclinationText: string
  tileFormat: string
  tileSize: number
  bounds: [number, number, number, number] | null
  tileBoundsByZoom: Record<number, TileZoomBounds>
  mapMetaUrl: string
  mapRootUrl: string
  courseUrl: string | null
  course: OrienteeringCourseData | null
  courseStatusText: string
  cpRadiusMeters: number
  gameMode: 'classic-sequential' | 'score-o'
  sessionCloseAfterMs: number
  sessionCloseWarningMs: number
  minCompletedControlsBeforeFinish: number
  punchPolicy: 'enter' | 'enter-confirm'
  punchRadiusMeters: number
  requiresFocusSelection: boolean
  skipEnabled: boolean
  skipRadiusMeters: number
  skipRequiresConfirm: boolean
  autoFinishOnLastControl: boolean
  controlScoreOverrides: Record<string, number>
  controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  defaultControlContentOverride: GameControlDisplayContentOverride | null
  defaultControlPointStyleOverride: ControlPointStyleEntry | null
  controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
  defaultLegStyleOverride: CourseLegStyleEntry | null
  legStyleOverrides: Record<number, CourseLegStyleEntry>
  defaultControlScore: number | null
  courseStyleConfig: CourseStyleConfig
  trackStyleConfig: TrackVisualizationConfig
  gpsMarkerStyleConfig: GpsMarkerStyleConfig
  telemetryConfig: TelemetryConfig
  audioConfig: GameAudioConfig
  hapticsConfig: GameHapticsConfig
  uiEffectsConfig: GameUiEffectsConfig
  systemSettingsConfig: SystemSettingsConfig
}

interface ParsedGameConfig {
  title: string
  appId: string
  schemaVersion: string
  version: string
  mapRoot: string
  mapMeta: string
  course: string | null
  cpRadiusMeters: number
  defaultZoom: number | null
  gameMode: 'classic-sequential' | 'score-o'
  sessionCloseAfterMs: number
  sessionCloseWarningMs: number
  minCompletedControlsBeforeFinish: number
  punchPolicy: 'enter' | 'enter-confirm'
  punchRadiusMeters: number
  requiresFocusSelection: boolean
  skipEnabled: boolean
  skipRadiusMeters: number
  skipRequiresConfirm: boolean
  autoFinishOnLastControl: boolean
  controlScoreOverrides: Record<string, number>
  controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  defaultControlContentOverride: GameControlDisplayContentOverride | null
  defaultControlPointStyleOverride: ControlPointStyleEntry | null
  controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
  defaultLegStyleOverride: CourseLegStyleEntry | null
  legStyleOverrides: Record<number, CourseLegStyleEntry>
  defaultControlScore: number | null
  courseStyleConfig: CourseStyleConfig
  trackStyleConfig: TrackVisualizationConfig
  gpsMarkerStyleConfig: GpsMarkerStyleConfig
  telemetryConfig: TelemetryConfig
  audioConfig: GameAudioConfig
  hapticsConfig: GameHapticsConfig
  uiEffectsConfig: GameUiEffectsConfig
  systemSettingsConfig: SystemSettingsConfig
  declinationDeg: number
}

interface ParsedMapMeta {
  tileSize: number
  minZoom: number
  maxZoom: number
  projection: string
  tileFormat: string
  tilePathTemplate: string
  bounds: [number, number, number, number] | null
}

function requestTextViaRequest(url: string): Promise<string> {
  return new Promise((resolve, reject) => {
    wx.request({
      url,
      method: 'GET',
      responseType: 'text' as any,
      success: (response) => {
        if (response.statusCode !== 200) {
          reject(new Error(`request失败: ${response.statusCode} ${url}`))
          return
        }

        if (typeof response.data === 'string') {
          resolve(response.data)
          return
        }

        resolve(JSON.stringify(response.data))
      },
      fail: () => {
        reject(new Error(`request失败: ${url}`))
      },
    })
  })
}

function requestTextViaDownload(url: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const fileSystemManager = wx.getFileSystemManager()
    wx.downloadFile({
      url,
      success: (response) => {
        if (response.statusCode !== 200 || !response.tempFilePath) {
          reject(new Error(`download失败: ${response.statusCode} ${url}`))
          return
        }

        fileSystemManager.readFile({
          filePath: response.tempFilePath,
          encoding: 'utf8',
          success: (readResult) => {
            if (typeof readResult.data === 'string') {
              resolve(readResult.data)
              return
            }

            reject(new Error(`read失败: ${url}`))
          },
          fail: () => {
            reject(new Error(`read失败: ${url}`))
          },
        })
      },
      fail: () => {
        reject(new Error(`download失败: ${url}`))
      },
    })
  })
}

async function requestText(url: string): Promise<string> {
  try {
    return await requestTextViaRequest(url)
  } catch (requestError) {
    try {
      return await requestTextViaDownload(url)
    } catch (downloadError) {
      const requestMessage = requestError instanceof Error ? requestError.message : 'request失败'
      const downloadMessage = downloadError instanceof Error ? downloadError.message : 'download失败'
      throw new Error(`${requestMessage}; ${downloadMessage}`)
    }
  }
}

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value))
}

function resolveUrl(baseUrl: string, relativePath: string): string {
  if (/^https?:\/\//i.test(relativePath)) {
    return relativePath
  }

  const originMatch = baseUrl.match(/^(https?:\/\/[^/]+)/i)
  const origin = originMatch ? originMatch[1] : ''
  if (relativePath.startsWith('/')) {
    return `${origin}${relativePath}`
  }

  const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
  const normalizedRelativePath = relativePath.replace(/^\.\//, '')
  return `${baseDir}${normalizedRelativePath}`
}

function formatDeclinationText(declinationDeg: number): string {
  const suffix = declinationDeg < 0 ? 'W' : 'E'
  return `${Math.abs(declinationDeg).toFixed(2)}° ${suffix}`
}

function parseDeclinationValue(rawValue: unknown): number {
  const numericValue = Number(rawValue)
  return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
}

function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
  const numericValue = Number(rawValue)
  return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
}

function parseNumber(rawValue: unknown, fallbackValue: number): number {
  const numericValue = Number(rawValue)
  return Number.isFinite(numericValue) ? numericValue : fallbackValue
}

function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
  if (typeof rawValue === 'boolean') {
    return rawValue
  }

  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'true') {
      return true
    }
    if (normalized === 'false') {
      return false
    }
  }

  return fallbackValue
}

function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
  return rawValue === 'enter' ? 'enter' : 'enter-confirm'
}

function parseSettingLockKey(rawValue: string): SettingLockKey | null {
  const normalized = rawValue.trim().toLowerCase()
  const table: Record<string, SettingLockKey> = {
    animationlevel: 'lockAnimationLevel',
    trackdisplaymode: 'lockTrackMode',
    trackmode: 'lockTrackMode',
    tracktaillength: 'lockTrackTailLength',
    trackcolorpreset: 'lockTrackColor',
    trackcolor: 'lockTrackColor',
    trackstyleprofile: 'lockTrackStyle',
    trackstyle: 'lockTrackStyle',
    gpsmarkervisible: 'lockGpsMarkerVisible',
    gpsmarkerstyle: 'lockGpsMarkerStyle',
    gpsmarkersize: 'lockGpsMarkerSize',
    gpsmarkercolorpreset: 'lockGpsMarkerColor',
    gpsmarkercolor: 'lockGpsMarkerColor',
    sidebuttonplacement: 'lockSideButtonPlacement',
    autorotateenabled: 'lockAutoRotate',
    autorotate: 'lockAutoRotate',
    compasstuningprofile: 'lockCompassTuning',
    compasstuning: 'lockCompassTuning',
    showcenterscaleruler: 'lockScaleRulerVisible',
    centerscaleruleranchormode: 'lockScaleRulerAnchor',
    centerruleranchor: 'lockScaleRulerAnchor',
    northreferencemode: 'lockNorthReference',
    northreference: 'lockNorthReference',
    heartratedevice: 'lockHeartRateDevice',
  }
  return table[normalized] || null
}

function assignParsedSettingValue(
  target: Partial<StoredUserSettings>,
  key: string,
  rawValue: unknown,
): void {
  const normalized = key.trim().toLowerCase()
  if (normalized === 'animationlevel') {
    if (rawValue === 'standard' || rawValue === 'lite') {
      target.animationLevel = rawValue
    }
    return
  }
  if (normalized === 'trackdisplaymode' || normalized === 'trackmode') {
    const parsed = parseTrackDisplayMode(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.mode)
    target.trackDisplayMode = parsed
    return
  }
  if (normalized === 'tracktaillength') {
    if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
      target.trackTailLength = rawValue
    }
    return
  }
  if (normalized === 'trackcolorpreset' || normalized === 'trackcolor') {
    const parsed = parseTrackColorPreset(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset)
    target.trackColorPreset = parsed
    return
  }
  if (normalized === 'trackstyleprofile' || normalized === 'trackstyle') {
    const parsed = parseTrackStyleProfile(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.style)
    target.trackStyleProfile = parsed
    return
  }
  if (normalized === 'gpsmarkervisible') {
    target.gpsMarkerVisible = parseBoolean(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.visible)
    return
  }
  if (normalized === 'gpsmarkerstyle') {
    target.gpsMarkerStyle = parseGpsMarkerStyleId(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.style)
    return
  }
  if (normalized === 'gpsmarkersize') {
    target.gpsMarkerSize = parseGpsMarkerSizePreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.size)
    return
  }
  if (normalized === 'gpsmarkercolorpreset' || normalized === 'gpsmarkercolor') {
    target.gpsMarkerColorPreset = parseGpsMarkerColorPreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset)
    return
  }
  if (normalized === 'sidebuttonplacement') {
    if (rawValue === 'left' || rawValue === 'right') {
      target.sideButtonPlacement = rawValue
    }
    return
  }
  if (normalized === 'autorotateenabled' || normalized === 'autorotate') {
    target.autoRotateEnabled = parseBoolean(rawValue, true)
    return
  }
  if (normalized === 'compasstuningprofile' || normalized === 'compasstuning') {
    if (rawValue === 'smooth' || rawValue === 'balanced' || rawValue === 'responsive') {
      target.compassTuningProfile = rawValue
    }
    return
  }
  if (normalized === 'northreferencemode' || normalized === 'northreference') {
    if (rawValue === 'magnetic' || rawValue === 'true') {
      target.northReferenceMode = rawValue
    }
    return
  }
  if (normalized === 'showcenterscaleruler') {
    target.showCenterScaleRuler = parseBoolean(rawValue, false)
    return
  }
  if (normalized === 'centerscaleruleranchormode' || normalized === 'centerruleranchor') {
    if (rawValue === 'screen-center' || rawValue === 'compass-center') {
      target.centerScaleRulerAnchorMode = rawValue
    }
  }
}

function parseSystemSettingsConfig(rawValue: unknown): SystemSettingsConfig {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return { values: {}, locks: {} }
  }

  const values: Partial<StoredUserSettings> = {}
  const locks: Partial<Record<SettingLockKey, boolean>> = {}
  for (const [key, entry] of Object.entries(normalized)) {
    const normalizedEntry = normalizeObjectRecord(entry)
    if (Object.keys(normalizedEntry).length) {
      const hasValue = Object.prototype.hasOwnProperty.call(normalizedEntry, 'value')
      const hasLocked = Object.prototype.hasOwnProperty.call(normalizedEntry, 'islocked')
      if (hasValue) {
        assignParsedSettingValue(values, key, normalizedEntry.value)
      }
      if (hasLocked) {
        const lockKey = parseSettingLockKey(key)
        if (lockKey) {
          locks[lockKey] = parseBoolean(normalizedEntry.islocked, false)
        }
      }
      continue
    }

    assignParsedSettingValue(values, key, entry)
  }

  return { values, locks }
}

function parseContentExperienceOverride(
  rawValue: unknown,
  baseUrl: string,
): GameContentExperienceConfigOverride | undefined {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return undefined
  }

  const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
  if (typeValue === 'native') {
    return {
      type: 'native',
      fallback: 'native',
      presentation: 'sheet',
    }
  }

  if (typeValue !== 'h5') {
    return undefined
  }

  const rawUrl = typeof normalized.url === 'string' ? normalized.url.trim() : ''
  if (!rawUrl) {
    return undefined
  }

  const bridgeValue = typeof normalized.bridge === 'string' && normalized.bridge.trim()
    ? normalized.bridge.trim()
    : 'content-v1'
  const rawPresentation = typeof normalized.presentation === 'string'
    ? normalized.presentation.trim().toLowerCase()
    : ''
  const presentationValue = rawPresentation === 'dialog' || rawPresentation === 'fullscreen'
    ? rawPresentation
    : 'sheet'

  return {
    type: 'h5',
    url: resolveUrl(baseUrl, rawUrl),
    bridge: bridgeValue,
    fallback: 'native',
    presentation: presentationValue,
  }
}

function parseControlDisplayContentOverride(
  rawValue: unknown,
  baseUrl: string,
): GameControlDisplayContentOverride | null {
  const item = normalizeObjectRecord(rawValue)
  if (!Object.keys(item).length) {
    return null
  }

  const titleValue = typeof item.title === 'string' ? item.title.trim() : ''
  const templateRaw = typeof item.template === 'string' ? item.template.trim().toLowerCase() : ''
  const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
    ? templateRaw
    : ''
  const bodyValue = typeof item.body === 'string' ? item.body.trim() : ''
  const clickTitleValue = typeof item.clickTitle === 'string' ? item.clickTitle.trim() : ''
  const clickBodyValue = typeof item.clickBody === 'string' ? item.clickBody.trim() : ''
  const autoPopupValue = item.autoPopup
  const onceValue = item.once
  const priorityNumeric = Number(item.priority)
  const ctasValue = parseContentCardCtas(item.ctas)
  const contentExperienceValue = parseContentExperienceOverride(item.contentExperience, baseUrl)
  const clickExperienceValue = parseContentExperienceOverride(item.clickExperience, baseUrl)
  const hasAutoPopup = typeof autoPopupValue === 'boolean'
  const hasOnce = typeof onceValue === 'boolean'
  const hasPriority = Number.isFinite(priorityNumeric)

  if (
    !templateValue
    && !titleValue
    && !bodyValue
    && !clickTitleValue
    && !clickBodyValue
    && !hasAutoPopup
    && !hasOnce
    && !hasPriority
    && !ctasValue
    && !contentExperienceValue
    && !clickExperienceValue
  ) {
    return null
  }

  const parsed: GameControlDisplayContentOverride = {}
  if (templateValue) {
    parsed.template = templateValue
  }
  if (titleValue) {
    parsed.title = titleValue
  }
  if (bodyValue) {
    parsed.body = bodyValue
  }
  if (clickTitleValue) {
    parsed.clickTitle = clickTitleValue
  }
  if (clickBodyValue) {
    parsed.clickBody = clickBodyValue
  }
  if (hasAutoPopup) {
    parsed.autoPopup = !!autoPopupValue
  }
  if (hasOnce) {
    parsed.once = !!onceValue
  }
  if (hasPriority) {
    parsed.priority = Math.max(0, Math.round(priorityNumeric))
  }
  if (ctasValue) {
    parsed.ctas = ctasValue
  }
  if (contentExperienceValue) {
    parsed.contentExperience = contentExperienceValue
  }
  if (clickExperienceValue) {
    parsed.clickExperience = clickExperienceValue
  }

  return parsed
}

function parseControlPointStyleOverride(
  rawValue: unknown,
  fallbackPointStyle: ControlPointStyleEntry,
): ControlPointStyleEntry | null {
  const item = normalizeObjectRecord(rawValue)
  if (!Object.keys(item).length) {
    return null
  }

  const rawPointStyle = getFirstDefined(item, ['pointstyle', 'style'])
  const rawPointColor = getFirstDefined(item, ['pointcolorhex', 'pointcolor', 'color', 'colorhex'])
  const rawPointSizeScale = getFirstDefined(item, ['pointsizescale', 'sizescale'])
  const rawPointAccentRingScale = getFirstDefined(item, ['pointaccentringscale', 'accentringscale'])
  const rawPointGlowStrength = getFirstDefined(item, ['pointglowstrength', 'glowstrength'])
  const rawPointLabelScale = getFirstDefined(item, ['pointlabelscale', 'labelscale'])
  const rawPointLabelColor = getFirstDefined(item, ['pointlabelcolorhex', 'pointlabelcolor', 'labelcolor', 'labelcolorhex'])
  if (
    rawPointStyle === undefined
    && rawPointColor === undefined
    && rawPointSizeScale === undefined
    && rawPointAccentRingScale === undefined
    && rawPointGlowStrength === undefined
    && rawPointLabelScale === undefined
    && rawPointLabelColor === undefined
  ) {
    return null
  }

  return {
    style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
    colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
    sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
    accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
    glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
    labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
    labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
  }
}

function parseLegStyleOverride(
  rawValue: unknown,
  fallbackLegStyle: CourseLegStyleEntry,
): CourseLegStyleEntry | null {
  const normalized = normalizeObjectRecord(rawValue)
  const rawStyle = getFirstDefined(normalized, ['style'])
  const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
  const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
  const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
  if (
    rawStyle === undefined
    && rawColor === undefined
    && rawWidthScale === undefined
    && rawGlowStrength === undefined
  ) {
    return null
  }

  return {
    style: parseCourseLegStyleId(rawStyle, fallbackLegStyle.style),
    colorHex: normalizeHexColor(rawColor, fallbackLegStyle.colorHex),
    widthScale: parsePositiveNumber(rawWidthScale, fallbackLegStyle.widthScale || 1),
    glowStrength: clamp(parseNumber(rawGlowStrength, fallbackLegStyle.glowStrength || 0), 0, 1.2),
  }
}

function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
  if (typeof rawValue !== 'string') {
    return 'classic-sequential'
  }

  const normalized = rawValue.trim().toLowerCase()
  if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') {
    return 'classic-sequential'
  }

  if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') {
    return 'score-o'
  }

  throw new Error(`暂不支持的 game.mode: ${rawValue}`)
}

function parseTrackDisplayMode(rawValue: unknown, fallbackValue: TrackDisplayMode): TrackDisplayMode {
  if (rawValue === 'none' || rawValue === 'full' || rawValue === 'tail') {
    return rawValue
  }
  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'none' || normalized === 'full' || normalized === 'tail') {
      return normalized
    }
  }
  return fallbackValue
}

function parseTrackStyleProfile(rawValue: unknown, fallbackValue: TrackStyleProfile): TrackStyleProfile {
  if (rawValue === 'classic' || rawValue === 'neon') {
    return rawValue
  }
  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'classic' || normalized === 'neon') {
      return normalized
    }
  }
  return fallbackValue
}

function parseTrackTailLengthPreset(rawValue: unknown, fallbackValue: TrackTailLengthPreset): TrackTailLengthPreset {
  if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
    return rawValue
  }
  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'short' || normalized === 'medium' || normalized === 'long') {
      return normalized
    }
  }
  return fallbackValue
}

function parseTrackColorPreset(rawValue: unknown, fallbackValue: TrackColorPreset): TrackColorPreset {
  if (
    rawValue === 'mint'
    || rawValue === 'cyan'
    || rawValue === 'sky'
    || rawValue === 'blue'
    || rawValue === 'violet'
    || rawValue === 'pink'
    || rawValue === 'orange'
    || rawValue === 'yellow'
  ) {
    return rawValue
  }
  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (
      normalized === 'mint'
      || normalized === 'cyan'
      || normalized === 'sky'
      || normalized === 'blue'
      || normalized === 'violet'
      || normalized === 'pink'
      || normalized === 'orange'
      || normalized === 'yellow'
    ) {
      return normalized
    }
  }
  return fallbackValue
}

function parseTrackVisualizationConfig(rawValue: unknown): TrackVisualizationConfig {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return DEFAULT_TRACK_VISUALIZATION_CONFIG
  }

  const fallback = DEFAULT_TRACK_VISUALIZATION_CONFIG
  const tailLength = parseTrackTailLengthPreset(getFirstDefined(normalized, ['taillength', 'tailpreset']), fallback.tailLength)
  const colorPreset = parseTrackColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
  const presetColors = TRACK_COLOR_PRESET_MAP[colorPreset]
  const rawTailMeters = getFirstDefined(normalized, ['tailmeters'])
  const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
  const rawHeadColorHex = getFirstDefined(normalized, ['headcolor', 'headcolorhex'])
  return {
    mode: parseTrackDisplayMode(getFirstDefined(normalized, ['mode']), fallback.mode),
    style: parseTrackStyleProfile(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
    tailLength,
    colorPreset,
    tailMeters: rawTailMeters !== undefined
      ? parsePositiveNumber(rawTailMeters, TRACK_TAIL_LENGTH_METERS[tailLength])
      : TRACK_TAIL_LENGTH_METERS[tailLength],
    tailMaxSeconds: parsePositiveNumber(getFirstDefined(normalized, ['tailmaxseconds', 'maxseconds']), fallback.tailMaxSeconds),
    fadeOutWhenStill: parseBoolean(getFirstDefined(normalized, ['fadeoutwhenstill', 'fadewhenstill']), fallback.fadeOutWhenStill),
    stillSpeedKmh: parsePositiveNumber(getFirstDefined(normalized, ['stillspeedkmh', 'stillspeed']), fallback.stillSpeedKmh),
    fadeOutDurationMs: parsePositiveNumber(getFirstDefined(normalized, ['fadeoutdurationms', 'fadeoutms']), fallback.fadeOutDurationMs),
    colorHex: normalizeHexColor(rawColorHex, presetColors.colorHex),
    headColorHex: normalizeHexColor(rawHeadColorHex, presetColors.headColorHex),
    widthPx: parsePositiveNumber(getFirstDefined(normalized, ['widthpx', 'width']), fallback.widthPx),
    headWidthPx: parsePositiveNumber(getFirstDefined(normalized, ['headwidthpx', 'headwidth']), fallback.headWidthPx),
    glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallback.glowStrength), 0, 1.5),
  }
}

function parseGpsMarkerStyleId(rawValue: unknown, fallbackValue: GpsMarkerStyleId): GpsMarkerStyleId {
  if (rawValue === 'dot' || rawValue === 'beacon' || rawValue === 'disc' || rawValue === 'badge') {
    return rawValue
  }
  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'dot' || normalized === 'beacon' || normalized === 'disc' || normalized === 'badge') {
      return normalized
    }
  }
  return fallbackValue
}

function parseGpsMarkerSizePreset(rawValue: unknown, fallbackValue: GpsMarkerSizePreset): GpsMarkerSizePreset {
  if (rawValue === 'small' || rawValue === 'medium' || rawValue === 'large') {
    return rawValue
  }
  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'small' || normalized === 'medium' || normalized === 'large') {
      return normalized
    }
  }
  return fallbackValue
}

function parseGpsMarkerColorPreset(rawValue: unknown, fallbackValue: GpsMarkerColorPreset): GpsMarkerColorPreset {
  if (
    rawValue === 'mint'
    || rawValue === 'cyan'
    || rawValue === 'sky'
    || rawValue === 'blue'
    || rawValue === 'violet'
    || rawValue === 'pink'
    || rawValue === 'orange'
    || rawValue === 'yellow'
  ) {
    return rawValue
  }
  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (
      normalized === 'mint'
      || normalized === 'cyan'
      || normalized === 'sky'
      || normalized === 'blue'
      || normalized === 'violet'
      || normalized === 'pink'
      || normalized === 'orange'
      || normalized === 'yellow'
    ) {
      return normalized
    }
  }
  return fallbackValue
}

function parseGpsMarkerAnimationProfile(
  rawValue: unknown,
  fallbackValue: GpsMarkerAnimationProfile,
): GpsMarkerAnimationProfile {
  if (rawValue === 'minimal' || rawValue === 'dynamic-runner' || rawValue === 'warning-reactive') {
    return rawValue
  }
  return fallbackValue
}

function parseGpsMarkerStyleConfig(rawValue: unknown): GpsMarkerStyleConfig {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return DEFAULT_GPS_MARKER_STYLE_CONFIG
  }

  const fallback = DEFAULT_GPS_MARKER_STYLE_CONFIG
  const colorPreset = parseGpsMarkerColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
  const presetColors = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
  const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
  const rawRingColorHex = getFirstDefined(normalized, ['ringcolor', 'ringcolorhex'])
  const rawIndicatorColorHex = getFirstDefined(normalized, ['indicatorcolor', 'indicatorcolorhex'])

  return {
    visible: parseBoolean(getFirstDefined(normalized, ['visible', 'show']), fallback.visible),
    style: parseGpsMarkerStyleId(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
    size: parseGpsMarkerSizePreset(getFirstDefined(normalized, ['size']), fallback.size),
    colorPreset,
    colorHex: typeof rawColorHex === 'string' && rawColorHex.trim() ? rawColorHex.trim() : presetColors.colorHex,
      ringColorHex: typeof rawRingColorHex === 'string' && rawRingColorHex.trim() ? rawRingColorHex.trim() : presetColors.ringColorHex,
      indicatorColorHex: typeof rawIndicatorColorHex === 'string' && rawIndicatorColorHex.trim() ? rawIndicatorColorHex.trim() : presetColors.indicatorColorHex,
      showHeadingIndicator: parseBoolean(getFirstDefined(normalized, ['showheadingindicator', 'showindicator']), fallback.showHeadingIndicator),
      animationProfile: parseGpsMarkerAnimationProfile(
        getFirstDefined(normalized, ['animationprofile', 'motionprofile']),
        fallback.animationProfile,
      ),
      motionState: fallback.motionState,
      motionIntensity: fallback.motionIntensity,
      pulseStrength: fallback.pulseStrength,
      headingAlpha: fallback.headingAlpha,
      effectScale: fallback.effectScale,
      wakeStrength: fallback.wakeStrength,
      warningGlowStrength: fallback.warningGlowStrength,
      indicatorScale: fallback.indicatorScale,
      logoScale: fallback.logoScale,
      logoUrl: typeof getFirstDefined(normalized, ['logourl']) === 'string' ? String(getFirstDefined(normalized, ['logourl'])).trim() : '',
      logoMode: 'center-badge',
    }
  }

function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return mergeTelemetryConfig()
  }

  const rawHeartRate = getFirstDefined(normalized, ['heartrate', 'heart_rate'])
  const normalizedHeartRate = normalizeObjectRecord(rawHeartRate)
  const ageRaw = getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage']) !== undefined
    ? getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage'])
    : getFirstDefined(normalized, ['age', 'userage', 'heartrateage', 'hrage'])
  const restingHeartRateRaw = getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
    !== undefined
    ? getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
    : getFirstDefined(normalized, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  const userWeightRaw = getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
    !== undefined
    ? getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
    : getFirstDefined(normalized, ['userweightkg', 'weightkg', 'weight'])

  const telemetryOverrides: Partial<TelemetryConfig> = {}
  if (ageRaw !== undefined) {
    telemetryOverrides.heartRateAge = Number(ageRaw)
  }
  if (restingHeartRateRaw !== undefined) {
    telemetryOverrides.restingHeartRateBpm = Number(restingHeartRateRaw)
  }
  if (userWeightRaw !== undefined) {
    telemetryOverrides.userWeightKg = Number(userWeightRaw)
  }

  return mergeTelemetryConfig(telemetryOverrides)
}


function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
  if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
    return {}
  }

  const normalized: Record<string, unknown> = {}
  const keys = Object.keys(rawValue as Record<string, unknown>)
  for (const key of keys) {
    normalized[key.toLowerCase()] = (rawValue as Record<string, unknown>)[key]
  }
  return normalized
}

function getFirstDefined(record: Record<string, unknown>, keys: string[]): unknown {
  for (const key of keys) {
    if (record[key] !== undefined) {
      return record[key]
    }
  }
  return undefined
}

function resolveAudioSrc(baseUrl: string, rawValue: unknown): string | undefined {
  if (typeof rawValue !== 'string') {
    return undefined
  }

  const trimmed = rawValue.trim()
  if (!trimmed) {
    return undefined
  }

  if (/^https?:\/\//i.test(trimmed)) {
    return trimmed
  }

  if (trimmed.startsWith('/assets/')) {
    return trimmed
  }

  if (trimmed.startsWith('assets/')) {
    return `/${trimmed}`
  }

  return resolveUrl(baseUrl, trimmed)
}

function buildAudioCueOverride(rawValue: unknown, baseUrl: string): PartialAudioCueConfig | null {
  if (typeof rawValue === 'string') {
    const src = resolveAudioSrc(baseUrl, rawValue)
    return src ? { src } : null
  }

  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return null
  }

  const src = resolveAudioSrc(baseUrl, getFirstDefined(normalized, ['src', 'url', 'path']))
  const volumeRaw = getFirstDefined(normalized, ['volume'])
  const loopRaw = getFirstDefined(normalized, ['loop'])
  const loopGapRaw = getFirstDefined(normalized, ['loopgapms', 'loopgap'])
  const cue: PartialAudioCueConfig = {}

  if (src) {
    cue.src = src
  }

  if (volumeRaw !== undefined) {
    cue.volume = parsePositiveNumber(volumeRaw, 1)
  }

  if (loopRaw !== undefined) {
    cue.loop = parseBoolean(loopRaw, false)
  }

  if (loopGapRaw !== undefined) {
    cue.loopGapMs = parsePositiveNumber(loopGapRaw, 0)
  }

  return cue.src || cue.volume !== undefined || cue.loop !== undefined || cue.loopGapMs !== undefined ? cue : null
}

function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return mergeGameAudioConfig()
  }

  const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  const cueMap: Array<{ key: AudioCueKey; aliases: string[] }> = [
    { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start', 'session_start'] },
    { key: 'control_completed:start', aliases: ['control_completed:start', 'controlcompleted:start', 'start_completed', 'startcomplete', 'start-complete'] },
    { key: 'control_completed:control', aliases: ['control_completed:control', 'controlcompleted:control', 'control_completed', 'controlcompleted', 'control_complete', 'controlcomplete'] },
    { key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
    { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
    { key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
    { key: 'guidance:distant', aliases: ['guidance:distant', 'guidance_distant', 'distant', 'far', 'far_distance'] },
    { key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
    { key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
  ]

  const cues: GameAudioConfigOverrides['cues'] = {}
  for (const cueDef of cueMap) {
    const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases)
    const cue = buildAudioCueOverride(cueRaw, baseUrl)
    if (cue) {
      cues[cueDef.key] = cue
    }
  }

  return mergeGameAudioConfig({
    enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
    masterVolume: normalized.mastervolume !== undefined
      ? parsePositiveNumber(normalized.mastervolume, 1)
      : normalized.volume !== undefined
        ? parsePositiveNumber(normalized.volume, 1)
        : undefined,
    obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
    distantDistanceMeters: normalized.distantdistancemeters !== undefined
      ? parsePositiveNumber(normalized.distantdistancemeters, 80)
      : normalized.distantdistance !== undefined
        ? parsePositiveNumber(normalized.distantdistance, 80)
        : normalized.fardistancemeters !== undefined
          ? parsePositiveNumber(normalized.fardistancemeters, 80)
          : normalized.fardistance !== undefined
            ? parsePositiveNumber(normalized.fardistance, 80)
            : undefined,
    approachDistanceMeters: normalized.approachdistancemeters !== undefined
      ? parsePositiveNumber(normalized.approachdistancemeters, 20)
      : normalized.approachdistance !== undefined
        ? parsePositiveNumber(normalized.approachdistance, 20)
        : undefined,
    readyDistanceMeters: normalized.readydistancemeters !== undefined
      ? parsePositiveNumber(normalized.readydistancemeters, 5)
      : normalized.readydistance !== undefined
        ? parsePositiveNumber(normalized.readydistance, 5)
        : normalized.punchreadydistancemeters !== undefined
          ? parsePositiveNumber(normalized.punchreadydistancemeters, 5)
          : normalized.punchreadydistance !== undefined
            ? parsePositiveNumber(normalized.punchreadydistance, 5)
            : undefined,
    cues,
  })
}

function parseLooseJsonObject(text: string): Record<string, unknown> {
  const parsed: Record<string, unknown> = {}
  const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
  let match: RegExpExecArray | null

  while ((match = pairPattern.exec(text))) {
    const rawValue = match[2]
    let value: unknown = rawValue

    if (rawValue === 'true' || rawValue === 'false') {
      value = rawValue === 'true'
    } else if (rawValue === 'null') {
      value = null
    } else if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
      value = match[3] || ''
    } else {
      const numericValue = Number(rawValue)
      value = Number.isFinite(numericValue) ? numericValue : rawValue
    }

    parsed[match[1]] = value
  }

  return parsed
}


function parseHapticPattern(rawValue: unknown): 'short' | 'long' | undefined {
  if (rawValue === 'short' || rawValue === 'long') {
    return rawValue
  }

  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'short' || normalized === 'long') {
      return normalized
    }
  }

  return undefined
}

function parsePunchFeedbackMotion(rawValue: unknown): 'none' | 'pop' | 'success' | 'warning' | undefined {
  if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'success' || rawValue === 'warning') {
    return rawValue
  }

  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'none' || normalized === 'pop' || normalized === 'success' || normalized === 'warning') {
      return normalized
    }
  }

  return undefined
}

function parseContentCardMotion(rawValue: unknown): 'none' | 'pop' | 'finish' | undefined {
  if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'finish') {
    return rawValue
  }

  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'none' || normalized === 'pop' || normalized === 'finish') {
      return normalized
    }
  }

  return undefined
}

function parsePunchButtonMotion(rawValue: unknown): 'none' | 'ready' | 'warning' | undefined {
  if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'warning') {
    return rawValue
  }

  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'none' || normalized === 'ready' || normalized === 'warning') {
      return normalized
    }
  }

  return undefined
}

function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' | 'finish' | undefined {
  if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'control' || rawValue === 'finish') {
    return rawValue
  }

  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'none' || normalized === 'ready' || normalized === 'control' || normalized === 'finish') {
      return normalized
    }
  }

  return undefined
}

function normalizeHexColor(rawValue: unknown, fallbackValue: string): string {
  if (typeof rawValue !== 'string') {
    return fallbackValue
  }

  const trimmed = rawValue.trim()
  if (!trimmed) {
    return fallbackValue
  }

  if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed)) {
    return trimmed.toLowerCase()
  }

  return fallbackValue
}

function parseControlPointStyleId(rawValue: unknown, fallbackValue: ControlPointStyleId): ControlPointStyleId {
  if (rawValue === 'classic-ring' || rawValue === 'solid-dot' || rawValue === 'double-ring' || rawValue === 'badge' || rawValue === 'pulse-core') {
    return rawValue
  }
  return fallbackValue
}

function parseCourseLegStyleId(rawValue: unknown, fallbackValue: CourseLegStyleId): CourseLegStyleId {
  if (rawValue === 'classic-leg' || rawValue === 'dashed-leg' || rawValue === 'glow-leg' || rawValue === 'progress-leg') {
    return rawValue
  }
  return fallbackValue
}

function parseControlPointStyleEntry(rawValue: unknown, fallbackValue: ControlPointStyleEntry): ControlPointStyleEntry {
  const normalized = normalizeObjectRecord(rawValue)
  const sizeScale = parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackValue.sizeScale || 1)
  const accentRingScale = parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackValue.accentRingScale || 0)
  const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
  const labelScale = parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackValue.labelScale || 1)
  return {
    style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
    colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
    sizeScale,
    accentRingScale,
    glowStrength: clamp(glowStrength, 0, 1.2),
    labelScale,
    labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackValue.labelColorHex || ''),
  }
}

function parseCourseLegStyleEntry(rawValue: unknown, fallbackValue: CourseLegStyleEntry): CourseLegStyleEntry {
  const normalized = normalizeObjectRecord(rawValue)
  const widthScale = parsePositiveNumber(getFirstDefined(normalized, ['widthscale']), fallbackValue.widthScale || 1)
  const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
  return {
    style: parseCourseLegStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
    colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
    widthScale,
    glowStrength: clamp(glowStrength, 0, 1.2),
  }
}

function parseScoreBandStyleEntries(rawValue: unknown, fallbackValue: ScoreBandStyleEntry[]): ScoreBandStyleEntry[] {
  if (!Array.isArray(rawValue) || !rawValue.length) {
    return fallbackValue
  }

  const parsed: ScoreBandStyleEntry[] = []
  for (let index = 0; index < rawValue.length; index += 1) {
    const item = rawValue[index]
    if (!item || typeof item !== 'object' || Array.isArray(item)) {
      continue
    }
    const normalized = normalizeObjectRecord(item)
    const fallbackItem = fallbackValue[Math.min(index, fallbackValue.length - 1)]
    const minValue = Number(getFirstDefined(normalized, ['min']))
    const maxValue = Number(getFirstDefined(normalized, ['max']))
      parsed.push({
        min: Number.isFinite(minValue) ? Math.round(minValue) : fallbackItem.min,
        max: Number.isFinite(maxValue) ? Math.round(maxValue) : fallbackItem.max,
        style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackItem.style),
        colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackItem.colorHex),
        sizeScale: parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackItem.sizeScale || 1),
        accentRingScale: parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackItem.accentRingScale || 0),
        glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackItem.glowStrength || 0), 0, 1.2),
        labelScale: parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackItem.labelScale || 1),
        labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackItem.labelColorHex || ''),
      })
    }

  return parsed.length ? parsed : fallbackValue
}

function parseCourseStyleConfig(rawValue: unknown): CourseStyleConfig {
  const normalized = normalizeObjectRecord(rawValue)
  const sequential = normalizeObjectRecord(getFirstDefined(normalized, ['sequential', 'classicsequential', 'classic']))
  const sequentialControls = normalizeObjectRecord(getFirstDefined(sequential, ['controls']))
  const sequentialLegs = normalizeObjectRecord(getFirstDefined(sequential, ['legs']))
  const scoreO = normalizeObjectRecord(getFirstDefined(normalized, ['scoreo', 'score']))
  const scoreOControls = normalizeObjectRecord(getFirstDefined(scoreO, ['controls']))

  return {
    sequential: {
      controls: {
        default: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default),
        current: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['current', 'active']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.current),
        completed: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.completed),
        skipped: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['skipped']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.skipped),
        start: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.start),
        finish: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.finish),
      },
      legs: {
        default: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default),
        completed: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.completed),
      },
    },
    scoreO: {
      controls: {
        default: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default),
        focused: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['focused', 'active']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.focused),
        collected: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['collected', 'completed']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.collected),
        start: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.start),
        finish: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.finish),
        scoreBands: parseScoreBandStyleEntries(getFirstDefined(scoreOControls, ['scorebands', 'bands']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.scoreBands),
      },
    },
  }
}

function parseIndexedLegOverrideKey(rawKey: string): number | null {
  if (typeof rawKey !== 'string') {
    return null
  }

  const normalized = rawKey.trim().toLowerCase()
  const legMatch = normalized.match(/^leg-(\d+)$/)
  if (legMatch) {
    const oneBasedIndex = Number(legMatch[1])
    return Number.isFinite(oneBasedIndex) && oneBasedIndex > 0 ? oneBasedIndex - 1 : null
  }

  const numericIndex = Number(normalized)
  return Number.isFinite(numericIndex) && numericIndex >= 0 ? Math.floor(numericIndex) : null
}

function parseContentCardCtas(rawValue: unknown): GameControlDisplayContentOverride['ctas'] | undefined {
  if (!Array.isArray(rawValue)) {
    return undefined
  }

  const parsed = rawValue
    .map((item) => {
      const normalized = normalizeObjectRecord(item)
      if (!Object.keys(normalized).length) {
        return null
      }
      const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
      if (typeValue !== 'detail' && typeValue !== 'photo' && typeValue !== 'audio' && typeValue !== 'quiz') {
        return null
      }
      const labelValue = typeof normalized.label === 'string' ? normalized.label.trim() : ''
      if (typeValue !== 'quiz') {
        return {
          type: typeValue as 'detail' | 'photo' | 'audio',
          ...(labelValue ? { label: labelValue } : {}),
        }
      }

      const quizRaw = {
        ...normalizeObjectRecord(normalized.quiz),
        ...(normalized.bonusScore !== undefined ? { bonusScore: normalized.bonusScore } : {}),
        ...(normalized.countdownSeconds !== undefined ? { countdownSeconds: normalized.countdownSeconds } : {}),
        ...(normalized.minValue !== undefined ? { minValue: normalized.minValue } : {}),
        ...(normalized.maxValue !== undefined ? { maxValue: normalized.maxValue } : {}),
        ...(normalized.allowSubtraction !== undefined ? { allowSubtraction: normalized.allowSubtraction } : {}),
      }
      const minValue = Number(quizRaw.minValue)
      const maxValue = Number(quizRaw.maxValue)
      const countdownSeconds = Number(quizRaw.countdownSeconds)
      const bonusScore = Number(quizRaw.bonusScore)

      return {
        type: 'quiz' as const,
        ...(labelValue ? { label: labelValue } : {}),
        ...(Number.isFinite(minValue) ? { minValue: Math.max(10, Math.round(minValue)) } : {}),
        ...(Number.isFinite(maxValue) ? { maxValue: Math.max(99, Math.round(maxValue)) } : {}),
        ...(typeof quizRaw.allowSubtraction === 'boolean' ? { allowSubtraction: quizRaw.allowSubtraction } : {}),
        ...(Number.isFinite(countdownSeconds) ? { countdownSeconds: Math.max(3, Math.round(countdownSeconds)) } : {}),
        ...(Number.isFinite(bonusScore) ? { bonusScore: Math.max(0, Math.round(bonusScore)) } : {}),
      }
    })
    .filter((item): item is NonNullable<typeof item> => !!item)

  return parsed.length ? parsed : undefined
}

function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
  if (rawValue === 'none' || rawValue === 'finish') {
    return rawValue
  }

  if (typeof rawValue === 'string') {
    const normalized = rawValue.trim().toLowerCase()
    if (normalized === 'none' || normalized === 'finish') {
      return normalized
    }
  }

  return undefined
}

function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null {
  if (typeof rawValue === 'boolean') {
    return { enabled: rawValue }
  }

  const pattern = parseHapticPattern(rawValue)
  if (pattern) {
    return { enabled: true, pattern }
  }

  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return null
  }

  const cue: PartialHapticCueConfig = {}
  if (normalized.enabled !== undefined) {
    cue.enabled = parseBoolean(normalized.enabled, true)
  }

  const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type']))
  if (parsedPattern) {
    cue.pattern = parsedPattern
  }

  return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null
}

function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return null
  }

  const cue: PartialUiCueConfig = {}
  if (normalized.enabled !== undefined) {
    cue.enabled = parseBoolean(normalized.enabled, true)
  }

  const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion']))
  if (punchFeedbackMotion) {
    cue.punchFeedbackMotion = punchFeedbackMotion
  }

  const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion']))
  if (contentCardMotion) {
    cue.contentCardMotion = contentCardMotion
  }

  const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion']))
  if (punchButtonMotion) {
    cue.punchButtonMotion = punchButtonMotion
  }

  const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion']))
  if (mapPulseMotion) {
    cue.mapPulseMotion = mapPulseMotion
  }

  const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion']))
  if (stageMotion) {
    cue.stageMotion = stageMotion
  }

  const durationRaw = getFirstDefined(normalized, ['durationms', 'duration'])
  if (durationRaw !== undefined) {
    cue.durationMs = parsePositiveNumber(durationRaw, 0)
  }

  return cue.enabled !== undefined ||
    cue.punchFeedbackMotion !== undefined ||
    cue.contentCardMotion !== undefined ||
    cue.punchButtonMotion !== undefined ||
    cue.mapPulseMotion !== undefined ||
    cue.stageMotion !== undefined ||
    cue.durationMs !== undefined
    ? cue
    : null
}

function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return mergeGameHapticsConfig()
  }

  const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
    { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
    { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
    { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
    { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
    { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
    { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
    { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
    { key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
    { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
    { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
  ]

  const cues: GameHapticsConfigOverrides['cues'] = {}
  for (const cueDef of cueMap) {
    const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
    if (cue) {
      cues[cueDef.key] = cue
    }
  }

  return mergeGameHapticsConfig({
    enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
    cues,
  })
}

function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
  const normalized = normalizeObjectRecord(rawValue)
  if (!Object.keys(normalized).length) {
    return mergeGameUiEffectsConfig()
  }

  const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
    { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
    { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
    { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
    { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
    { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
    { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
    { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
    { key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
    { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
    { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
  ]

  const cues: GameUiEffectsConfigOverrides['cues'] = {}
  for (const cueDef of cueMap) {
    const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
    if (cue) {
      cues[cueDef.key] = cue
    }
  }

  return mergeGameUiEffectsConfig({
    enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
    cues,
  })
}

function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig {
  let parsed: Record<string, unknown>
  try {
    parsed = JSON.parse(text)
  } catch {
    parsed = parseLooseJsonObject(text)
  }

  const normalized: Record<string, unknown> = {}
  const keys = Object.keys(parsed)
  for (const key of keys) {
    normalized[key.toLowerCase()] = parsed[key]
  }

  const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game)
    ? parsed.game as Record<string, unknown>
    : null
  const rawApp = parsed.app && typeof parsed.app === 'object' && !Array.isArray(parsed.app)
    ? parsed.app as Record<string, unknown>
    : null
  const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
    ? parsed.map as Record<string, unknown>
    : null
  const rawSettings = parsed.settings && typeof parsed.settings === 'object' && !Array.isArray(parsed.settings)
    ? parsed.settings as Record<string, unknown>
    : null
  const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
    ? parsed.playfield as Record<string, unknown>
    : null
  const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
    ? rawPlayfield.source as Record<string, unknown>
    : null
  const rawGamePresentation = rawGame && rawGame.presentation && typeof rawGame.presentation === 'object' && !Array.isArray(rawGame.presentation)
    ? rawGame.presentation as Record<string, unknown>
    : null
  const normalizedGamePresentation = normalizeObjectRecord(rawGamePresentation)
  const normalizedGame: Record<string, unknown> = {}
  if (rawGame) {
    const gameKeys = Object.keys(rawGame)
    for (const key of gameKeys) {
      normalizedGame[key.toLowerCase()] = rawGame[key]
    }
  }
  const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio
  const rawTelemetry = rawGame && rawGame.telemetry !== undefined ? rawGame.telemetry : parsed.telemetry
  const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
  const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
    ? rawGame.uiEffects
    : rawGame && rawGame.uieffects !== undefined
      ? rawGame.uieffects
      : rawGame && rawGame.ui !== undefined
        ? rawGame.ui
        : (parsed as Record<string, unknown>).uiEffects !== undefined
          ? (parsed as Record<string, unknown>).uiEffects
          : (parsed as Record<string, unknown>).uieffects !== undefined
            ? (parsed as Record<string, unknown>).uieffects
            : (parsed as Record<string, unknown>).ui

  const rawSession = rawGame && rawGame.session && typeof rawGame.session === 'object' && !Array.isArray(rawGame.session)
    ? rawGame.session as Record<string, unknown>
    : null
  const rawPunch = rawGame && rawGame.punch && typeof rawGame.punch === 'object' && !Array.isArray(rawGame.punch)
    ? rawGame.punch as Record<string, unknown>
    : null
  const rawSequence = rawGame && rawGame.sequence && typeof rawGame.sequence === 'object' && !Array.isArray(rawGame.sequence)
    ? rawGame.sequence as Record<string, unknown>
    : null
  const rawSkip = rawSequence && rawSequence.skip && typeof rawSequence.skip === 'object' && !Array.isArray(rawSequence.skip)
    ? rawSequence.skip as Record<string, unknown>
    : null
  const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
    ? rawGame.scoring as Record<string, unknown>
    : null
  const rawGameSettings = rawGame && rawGame.settings && typeof rawGame.settings === 'object' && !Array.isArray(rawGame.settings)
    ? rawGame.settings as Record<string, unknown>
    : null

  const mapRoot = rawMap && typeof rawMap.tiles === 'string'
    ? rawMap.tiles
    : typeof normalized.map === 'string'
      ? normalized.map
      : ''
  const mapMeta = rawMap && typeof rawMap.mapmeta === 'string'
    ? rawMap.mapmeta
    : typeof normalized.mapmeta === 'string'
      ? normalized.mapmeta
      : ''
  if (!mapRoot || !mapMeta) {
    throw new Error('game.json 缺少 map 或 mapmeta 字段')
  }

  const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
  const gameMode = parseGameMode(modeValue)
  const modeDefaults = getGameModeDefaults(gameMode)
  const fallbackPointStyle = gameMode === 'score-o'
    ? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
    : DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
  const fallbackLegStyle = DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default
  const rawControlDefaults = rawPlayfield && rawPlayfield.controlDefaults && typeof rawPlayfield.controlDefaults === 'object' && !Array.isArray(rawPlayfield.controlDefaults)
    ? rawPlayfield.controlDefaults as Record<string, unknown>
    : null
  const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
    ? rawPlayfield.controlOverrides as Record<string, unknown>
    : null
  const defaultControlScoreFromPlayfield = rawControlDefaults
    ? Number(getFirstDefined(normalizeObjectRecord(rawControlDefaults), ['score']))
    : Number.NaN
  const defaultControlContentOverride = parseControlDisplayContentOverride(rawControlDefaults, gameConfigUrl)
  const defaultControlPointStyleOverride = parseControlPointStyleOverride(rawControlDefaults, fallbackPointStyle)
  const controlScoreOverrides: Record<string, number> = {}
  const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
  const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
  if (rawControlOverrides) {
    const keys = Object.keys(rawControlOverrides)
    for (const key of keys) {
      const item = rawControlOverrides[key]
      if (!item || typeof item !== 'object' || Array.isArray(item)) {
        continue
      }
      const scoreValue = Number((item as Record<string, unknown>).score)
      if (Number.isFinite(scoreValue)) {
        controlScoreOverrides[key] = scoreValue
      }
      const styleOverride = parseControlPointStyleOverride(item, fallbackPointStyle)
      if (styleOverride) {
        controlPointStyleOverrides[key] = styleOverride
      }
      const contentOverride = parseControlDisplayContentOverride(item, gameConfigUrl)
      if (contentOverride) {
        controlContentOverrides[key] = contentOverride
      }
    }
  }

  const rawLegDefaults = rawPlayfield && rawPlayfield.legDefaults && typeof rawPlayfield.legDefaults === 'object' && !Array.isArray(rawPlayfield.legDefaults)
    ? rawPlayfield.legDefaults as Record<string, unknown>
    : null
  const defaultLegStyleOverride = parseLegStyleOverride(rawLegDefaults, fallbackLegStyle)
  const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
    ? rawPlayfield.legOverrides as Record<string, unknown>
    : null
  const legStyleOverrides: Record<number, CourseLegStyleEntry> = {}
  if (rawLegOverrides) {
    const legKeys = Object.keys(rawLegOverrides)
    for (const rawKey of legKeys) {
      const item = rawLegOverrides[rawKey]
      const index = parseIndexedLegOverrideKey(rawKey)
      if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
        continue
      }
      const legOverride = parseLegStyleOverride(item, fallbackLegStyle)
      if (!legOverride) {
        continue
      }
      legStyleOverrides[index] = legOverride
    }
  }

  const punchRadiusMeters = parsePositiveNumber(
    rawPunch && rawPunch.radiusMeters !== undefined
      ? rawPunch.radiusMeters
      : normalizedGame.punchradiusmeters !== undefined
        ? normalizedGame.punchradiusmeters
        : normalizedGame.punchradius !== undefined
          ? normalizedGame.punchradius
          : normalized.punchradiusmeters !== undefined
            ? normalized.punchradiusmeters
            : normalized.punchradius,
    5,
  )
  const sessionCloseAfterMs = parsePositiveNumber(
    rawSession && rawSession.closeAfterMs !== undefined
      ? rawSession.closeAfterMs
      : rawSession && rawSession.sessionCloseAfterMs !== undefined
        ? rawSession.sessionCloseAfterMs
        : normalizedGame.sessioncloseafterms !== undefined
          ? normalizedGame.sessioncloseafterms
          : normalized.sessioncloseafterms,
    modeDefaults.sessionCloseAfterMs,
  )
  const sessionCloseWarningMs = parsePositiveNumber(
    rawSession && rawSession.closeWarningMs !== undefined
      ? rawSession.closeWarningMs
      : rawSession && rawSession.sessionCloseWarningMs !== undefined
        ? rawSession.sessionCloseWarningMs
        : normalizedGame.sessionclosewarningms !== undefined
          ? normalizedGame.sessionclosewarningms
          : normalized.sessionclosewarningms,
    modeDefaults.sessionCloseWarningMs,
  )
  const minCompletedControlsBeforeFinish = Math.max(0, Math.floor(parseNumber(
    rawSession && rawSession.minCompletedControlsBeforeFinish !== undefined
      ? rawSession.minCompletedControlsBeforeFinish
      : rawSession && rawSession.minControlsBeforeFinish !== undefined
        ? rawSession.minControlsBeforeFinish
        : normalizedGame.mincompletedcontrolsbeforefinish !== undefined
          ? normalizedGame.mincompletedcontrolsbeforefinish
          : normalizedGame.mincontrolsbeforefinish !== undefined
            ? normalizedGame.mincontrolsbeforefinish
            : normalized.mincompletedcontrolsbeforefinish !== undefined
              ? normalized.mincompletedcontrolsbeforefinish
              : normalized.mincontrolsbeforefinish,
    modeDefaults.minCompletedControlsBeforeFinish,
  )))
  const requiresFocusSelection = parseBoolean(
    rawPunch && rawPunch.requiresFocusSelection !== undefined
      ? rawPunch.requiresFocusSelection
      : normalizedGame.requiresfocusselection !== undefined
        ? normalizedGame.requiresfocusselection
        : rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
          ? (rawPunch as Record<string, unknown>).requiresfocusselection
          : normalized.requiresfocusselection,
    modeDefaults.requiresFocusSelection,
  )
  const skipEnabled = parseBoolean(
    rawSkip && rawSkip.enabled !== undefined
      ? rawSkip.enabled
      : normalizedGame.skipenabled !== undefined
        ? normalizedGame.skipenabled
        : normalized.skipenabled,
    modeDefaults.skipEnabled,
  )
  const skipRadiusMeters = parsePositiveNumber(
    rawSkip && rawSkip.radiusMeters !== undefined
      ? rawSkip.radiusMeters
      : normalizedGame.skipradiusmeters !== undefined
        ? normalizedGame.skipradiusmeters
        : normalizedGame.skipradius !== undefined
          ? normalizedGame.skipradius
          : normalized.skipradiusmeters !== undefined
            ? normalized.skipradiusmeters
            : normalized.skipradius,
    getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
  )
  const autoFinishOnLastControl = parseBoolean(
    rawSession && rawSession.autoFinishOnLastControl !== undefined
      ? rawSession.autoFinishOnLastControl
      : normalizedGame.autofinishonlastcontrol !== undefined
        ? normalizedGame.autofinishonlastcontrol
        : normalized.autofinishonlastcontrol,
    modeDefaults.autoFinishOnLastControl,
  )

  return {
    title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
    appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
    schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1',
    version: typeof parsed.version === 'string' ? parsed.version : '',
    mapRoot,
    mapMeta,
    course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string'
      ? rawPlayfieldSource.url
      : typeof normalized.course === 'string'
        ? normalized.course
        : null,
    cpRadiusMeters: parsePositiveNumber(
      rawPlayfield && rawPlayfield.CPRadius !== undefined ? rawPlayfield.CPRadius : normalized.cpradius,
      5,
    ),
    defaultZoom: rawMap && rawMap.initialView && typeof rawMap.initialView === 'object' && !Array.isArray(rawMap.initialView)
      ? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
      : null,
    gameMode,
    sessionCloseAfterMs,
    sessionCloseWarningMs,
    minCompletedControlsBeforeFinish,
    punchPolicy: parsePunchPolicy(
      rawPunch && rawPunch.policy !== undefined
        ? rawPunch.policy
        : normalizedGame.punchpolicy !== undefined
          ? normalizedGame.punchpolicy
          : normalized.punchpolicy,
    ),
    punchRadiusMeters,
    requiresFocusSelection,
    skipEnabled,
    skipRadiusMeters,
    skipRequiresConfirm: parseBoolean(
      rawSkip && rawSkip.requiresConfirm !== undefined
        ? rawSkip.requiresConfirm
        : normalizedGame.skiprequiresconfirm !== undefined
          ? normalizedGame.skiprequiresconfirm
          : normalized.skiprequiresconfirm,
      modeDefaults.skipRequiresConfirm,
    ),
    autoFinishOnLastControl,
    controlScoreOverrides,
    controlContentOverrides,
    defaultControlContentOverride,
    defaultControlPointStyleOverride,
    controlPointStyleOverrides,
    defaultLegStyleOverride,
    legStyleOverrides,
    defaultControlScore: resolveDefaultControlScore(
      gameMode,
      Number.isFinite(defaultControlScoreFromPlayfield)
        ? defaultControlScoreFromPlayfield
        : rawScoring && rawScoring.defaultControlScore !== undefined
        ? parsePositiveNumber(rawScoring.defaultControlScore, modeDefaults.defaultControlScore)
        : null,
    ),
    courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
    trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
    gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
    telemetryConfig: parseTelemetryConfig(rawTelemetry),
    audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
    hapticsConfig: parseHapticsConfig(rawHaptics),
    uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
    systemSettingsConfig: parseSystemSettingsConfig(rawGameSettings || rawSettings),
    declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
  }
}

function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig {
  const config: Record<string, string> = {}
  const lines = text.split(/\r?\n/)

  for (const rawLine of lines) {
    const line = rawLine.trim()
    if (!line || line.startsWith('#')) {
      continue
    }

    const match = line.match(/^([A-Za-z0-9_-]+)\s*(?:=|:)\s*(.+)$/)
    if (!match) {
      continue
    }

    config[match[1].trim().toLowerCase()] = match[2].trim()
  }

  const mapRoot = config.map
  const mapMeta = config.mapmeta
  if (!mapRoot || !mapMeta) {
    throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
  }

  const gameMode = parseGameMode(config.gamemode)
  const modeDefaults = getGameModeDefaults(gameMode)
  const punchRadiusMeters = parsePositiveNumber(
    config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
    5,
  )

  return {
    title: '',
    appId: '',
    schemaVersion: '1',
    version: '',
    mapRoot,
    mapMeta,
    course: typeof config.course === 'string' ? config.course : null,
    cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
    defaultZoom: null,
    gameMode,
    sessionCloseAfterMs: modeDefaults.sessionCloseAfterMs,
    sessionCloseWarningMs: modeDefaults.sessionCloseWarningMs,
    minCompletedControlsBeforeFinish: modeDefaults.minCompletedControlsBeforeFinish,
    punchPolicy: parsePunchPolicy(config.punchpolicy),
    punchRadiusMeters,
    requiresFocusSelection: parseBoolean(config.requiresfocusselection, modeDefaults.requiresFocusSelection),
    skipEnabled: parseBoolean(config.skipenabled, modeDefaults.skipEnabled),
    skipRadiusMeters: parsePositiveNumber(
      config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
      getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
    ),
    skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, modeDefaults.skipRequiresConfirm),
    autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, modeDefaults.autoFinishOnLastControl),
    controlScoreOverrides: {},
    controlContentOverrides: {},
    defaultControlContentOverride: null,
    defaultControlPointStyleOverride: null,
    controlPointStyleOverrides: {},
    defaultLegStyleOverride: null,
    legStyleOverrides: {},
    defaultControlScore: modeDefaults.defaultControlScore,
    courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
    trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
    gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
    telemetryConfig: parseTelemetryConfig({
      heartRate: {
        age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
        restingHeartRateBpm: config.restingheartratebpm !== undefined
          ? config.restingheartratebpm
          : config.restingheartrate !== undefined
            ? config.restingheartrate
            : config.telemetryrestingheartratebpm !== undefined
              ? config.telemetryrestingheartratebpm
              : config.telemetryrestingheartrate,
      },
    }),
    audioConfig: parseAudioConfig({
      enabled: config.audioenabled,
      masterVolume: config.audiomastervolume,
      obeyMuteSwitch: config.audioobeymuteswitch,
      distantDistanceMeters: config.audiodistantdistancemeters !== undefined
        ? config.audiodistantdistancemeters
        : config.audiodistantdistance !== undefined
          ? config.audiodistantdistance
          : config.audiofardistancemeters !== undefined
            ? config.audiofardistancemeters
            : config.audiofardistance,
      approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
      readyDistanceMeters: config.audioreadydistancemeters !== undefined
        ? config.audioreadydistancemeters
        : config.audioreadydistance !== undefined
          ? config.audioreadydistance
          : config.audiopunchreadydistancemeters !== undefined
            ? config.audiopunchreadydistancemeters
            : config.audiopunchreadydistance,
      cues: {
        session_started: config.audiosessionstarted,
        'control_completed:start': config.audiostartcomplete,
        'control_completed:control': config.audiocontrolcomplete,
        'control_completed:finish': config.audiofinishcomplete,
        'punch_feedback:warning': config.audiowarning,
        'guidance:searching': config.audiosearching,
        'guidance:distant': config.audiodistant,
        'guidance:approaching': config.audioapproaching,
        'guidance:ready': config.audioready,
      },
    }, gameConfigUrl),
    hapticsConfig: parseHapticsConfig({
      enabled: config.hapticsenabled,
      cues: {
        session_started: config.hapticsstart,
        session_finished: config.hapticsfinish,
        'control_completed:start': config.hapticsstartcomplete,
        'control_completed:control': config.hapticscontrolcomplete,
        'control_completed:finish': config.hapticsfinishcomplete,
        'punch_feedback:warning': config.hapticswarning,
        'guidance:searching': config.hapticssearching,
        'guidance:distant': config.hapticsdistant,
        'guidance:approaching': config.hapticsapproaching,
        'guidance:ready': config.hapticsready,
      },
    }),
    uiEffectsConfig: parseUiEffectsConfig({
      enabled: config.uieffectsenabled,
      cues: {
        session_started: { enabled: config.uistartenabled, punchButtonMotion: config.uistartbuttonmotion },
        session_finished: { enabled: config.uifinishenabled, contentCardMotion: config.uifinishcardmotion },
        'control_completed:start': { enabled: config.uistartcompleteenabled, contentCardMotion: config.uistartcompletecardmotion, punchFeedbackMotion: config.uistartcompletetoastmotion },
        'control_completed:control': { enabled: config.uicontrolcompleteenabled, contentCardMotion: config.uicontrolcompletecardmotion, punchFeedbackMotion: config.uicontrolcompletetoastmotion },
        'control_completed:finish': { enabled: config.uifinishcompleteenabled, contentCardMotion: config.uifinishcompletecardmotion, punchFeedbackMotion: config.uifinishcompletetoastmotion },
        'punch_feedback:warning': { enabled: config.uiwarningenabled, punchFeedbackMotion: config.uiwarningtoastmotion, punchButtonMotion: config.uiwarningbuttonmotion, durationMs: config.uiwarningdurationms },
        'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
      },
    }),
    systemSettingsConfig: { values: {}, locks: {} },
    declinationDeg: parseDeclinationValue(config.declination),
  }
}

function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig {
  const trimmedText = text.trim()
  const isJson =
    trimmedText.startsWith('{') ||
    trimmedText.startsWith('[') ||
    /\.json(?:[?#].*)?$/i.test(gameConfigUrl)

  return isJson ? parseGameConfigFromJson(trimmedText, gameConfigUrl) : parseGameConfigFromYaml(trimmedText, gameConfigUrl)
}

function extractStringField(text: string, key: string): string | null {
  const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)
  const match = text.match(pattern)
  return match ? match[1] : null
}

function extractNumberField(text: string, key: string): number | null {
  const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`)
  const match = text.match(pattern)
  if (!match) {
    return null
  }

  const value = Number(match[1])
  return Number.isFinite(value) ? value : null
}

function extractNumberArrayField(text: string, key: string): number[] | null {
  const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`)
  const match = text.match(pattern)
  if (!match) {
    return null
  }

  const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g)
  if (!numberMatches || !numberMatches.length) {
    return null
  }

  const values = numberMatches
    .map((item) => Number(item))
    .filter((item) => Number.isFinite(item))

  return values.length ? values : null
}

function parseMapMeta(text: string): ParsedMapMeta {
  const tileSizeField = extractNumberField(text, 'tileSize')
  const tileSize = tileSizeField === null ? 256 : tileSizeField
  const minZoom = extractNumberField(text, 'minZoom')
  const maxZoom = extractNumberField(text, 'maxZoom')
  const projectionField = extractStringField(text, 'projection')
  const projection = projectionField === null ? 'EPSG:3857' : projectionField
  const tilePathTemplate = extractStringField(text, 'tilePathTemplate')
  const tileFormatFromField = extractStringField(text, 'tileFormat')
  const boundsValues = extractNumberArrayField(text, 'bounds')

  if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) {
    throw new Error('meta.json 缺少必要字段')
  }

  let tileFormat = tileFormatFromField || ''
  if (!tileFormat) {
    const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/)
    tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png'
  }

  return {
    tileSize,
    minZoom: minZoom as number,
    maxZoom: maxZoom as number,
    projection,
    tileFormat,
    tilePathTemplate,
    bounds: boundsValues && boundsValues.length >= 4
      ? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]]
      : null,
  }
}

function getBoundsCorners(
  bounds: [number, number, number, number],
  projection: string,
): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } {
  if (projection === 'EPSG:3857') {
    const minX = bounds[0]
    const minY = bounds[1]
    const maxX = bounds[2]
    const maxY = bounds[3]
    return {
      northWest: webMercatorToLonLat({ x: minX, y: maxY }),
      southEast: webMercatorToLonLat({ x: maxX, y: minY }),
      center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }),
    }
  }

  if (projection === 'EPSG:4326') {
    const minLon = bounds[0]
    const minLat = bounds[1]
    const maxLon = bounds[2]
    const maxLat = bounds[3]
    return {
      northWest: { lon: minLon, lat: maxLat },
      southEast: { lon: maxLon, lat: minLat },
      center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 },
    }
  }

  throw new Error(`暂不支持的投影: ${projection}`)
}

function buildTileBoundsByZoom(
  bounds: [number, number, number, number] | null,
  projection: string,
  minZoom: number,
  maxZoom: number,
): Record<number, TileZoomBounds> {
  const boundsByZoom: Record<number, TileZoomBounds> = {}
  if (!bounds) {
    return boundsByZoom
  }

  const corners = getBoundsCorners(bounds, projection)
  for (let zoom = minZoom; zoom <= maxZoom; zoom += 1) {
    const northWestWorld = lonLatToWorldTile(corners.northWest, zoom)
    const southEastWorld = lonLatToWorldTile(corners.southEast, zoom)
    const minX = Math.floor(Math.min(northWestWorld.x, southEastWorld.x))
    const maxX = Math.ceil(Math.max(northWestWorld.x, southEastWorld.x)) - 1
    const minY = Math.floor(Math.min(northWestWorld.y, southEastWorld.y))
    const maxY = Math.ceil(Math.max(northWestWorld.y, southEastWorld.y)) - 1

    boundsByZoom[zoom] = {
      minX,
      maxX,
      minY,
      maxY,
    }
  }

  return boundsByZoom
}

function getProjectionModeText(projection: string): string {
  return `${projection} -> XYZ Tile -> Camera -> Screen`
}

export function isTileWithinBounds(
  tileBoundsByZoom: Record<number, TileZoomBounds> | null | undefined,
  zoom: number,
  x: number,
  y: number,
): boolean {
  if (!tileBoundsByZoom) {
    return true
  }

  const bounds = tileBoundsByZoom[zoom]
  if (!bounds) {
    return true
  }

  return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY
}

export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<RemoteMapConfig> {
  const gameConfigText = await requestText(gameConfigUrl)
  const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
  const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
  const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
  const courseUrl = gameConfig.course ? resolveUrl(gameConfigUrl, gameConfig.course) : null
  const mapMetaText = await requestText(mapMetaUrl)
  const mapMeta = parseMapMeta(mapMetaText)

  let course: OrienteeringCourseData | null = null
  let courseStatusText = courseUrl ? '路线待加载' : '未配置路线'
  if (courseUrl) {
    try {
      const courseText = await requestText(courseUrl)
      course = parseOrienteeringCourseKml(courseText)
      courseStatusText = `路线已载入 (${course.layers.controls.length} controls)`
    } catch (error) {
      const message = error instanceof Error ? error.message : '未知错误'
      courseStatusText = `路线加载失败: ${message}`
    }
  }

  const defaultZoom = clamp(gameConfig.defaultZoom || 17, mapMeta.minZoom, mapMeta.maxZoom)
  const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
  const centerWorldTile = boundsCorners
    ? lonLatToWorldTile(boundsCorners.center, defaultZoom)
    : { x: 0, y: 0 }

  return {
    configTitle: gameConfig.title || '未命名配置',
    configAppId: gameConfig.appId || '',
    configSchemaVersion: gameConfig.schemaVersion || '1',
    configVersion: gameConfig.version || '',
    tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
    minZoom: mapMeta.minZoom,
    maxZoom: mapMeta.maxZoom,
    defaultZoom,
    initialCenterTileX: Math.round(centerWorldTile.x),
    initialCenterTileY: Math.round(centerWorldTile.y),
    projection: mapMeta.projection,
    projectionModeText: getProjectionModeText(mapMeta.projection),
    magneticDeclinationDeg: gameConfig.declinationDeg,
    magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg),
    tileFormat: mapMeta.tileFormat,
    tileSize: mapMeta.tileSize,
    bounds: mapMeta.bounds,
    tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
    mapMetaUrl,
    mapRootUrl,
    courseUrl,
    course,
    courseStatusText,
    cpRadiusMeters: gameConfig.cpRadiusMeters,
    gameMode: gameConfig.gameMode,
    sessionCloseAfterMs: gameConfig.sessionCloseAfterMs,
    sessionCloseWarningMs: gameConfig.sessionCloseWarningMs,
    minCompletedControlsBeforeFinish: gameConfig.minCompletedControlsBeforeFinish,
    punchPolicy: gameConfig.punchPolicy,
    punchRadiusMeters: gameConfig.punchRadiusMeters,
    requiresFocusSelection: gameConfig.requiresFocusSelection,
    skipEnabled: gameConfig.skipEnabled,
    skipRadiusMeters: gameConfig.skipRadiusMeters,
    skipRequiresConfirm: gameConfig.skipRequiresConfirm,
    autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
    controlScoreOverrides: gameConfig.controlScoreOverrides,
    controlContentOverrides: gameConfig.controlContentOverrides,
    defaultControlContentOverride: gameConfig.defaultControlContentOverride,
    defaultControlPointStyleOverride: gameConfig.defaultControlPointStyleOverride,
    controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
    defaultLegStyleOverride: gameConfig.defaultLegStyleOverride,
    legStyleOverrides: gameConfig.legStyleOverrides,
    defaultControlScore: gameConfig.defaultControlScore,
    courseStyleConfig: gameConfig.courseStyleConfig,
    trackStyleConfig: gameConfig.trackStyleConfig,
    gpsMarkerStyleConfig: gameConfig.gpsMarkerStyleConfig,
    telemetryConfig: gameConfig.telemetryConfig,
    audioConfig: gameConfig.audioConfig,
    hapticsConfig: gameConfig.hapticsConfig,
    uiEffectsConfig: gameConfig.uiEffectsConfig,
    systemSettingsConfig: gameConfig.systemSettingsConfig,
  }
}



