import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getPoiRepository } from '../src/data/poi'
import {
  adjustLocalPlan,
  classifyDurationFit,
  createLocalPlan,
  createRecommendedPlan,
  hasSignificantDurationGap,
  LEGACY_PLACE_ID_TO_POI_ID,
  normalizePlanningRequest,
  normalizePlanResponse,
  normalizeTravelPreferences,
  type Pace,
  type PlanningRequest,
  type PlanResponse,
  resolveCanonicalPoiId,
  type TravelPreferences,
  TravelValidationError,
} from '../src/domain/travel'
import {
  createPlan,
  getSessionPlanningOrigin,
  loadPlan,
  loadPlanningRequest,
  loadPreferences,
  PLAN_STORAGE_KEY,
  PLANNING_REQUEST_STORAGE_KEY,
  PREFERENCES_STORAGE_KEY,
  savePlan,
  savePlanningRequest,
  savePreferences,
} from '../src/services/travel-assistant'

function preferences(overrides: Partial<TravelPreferences> = {}): TravelPreferences {
  return {
    destination: '深圳市光明区',
    themes: ['亲子'],
    duration: 'half_day',
    pace: 'moderate',
    interests: ['自然风光'],
    adults: 2,
    children: 1,
    childAges: [6],
    transport: 'public_transport',
    budgetLevel: 'standard',
    extraRequirements: '',
    ...overrides,
  }
}

function deepClone<T>(value: T): T {
  return JSON.parse(JSON.stringify(value)) as T
}

function storageEnvelope(payload: unknown) {
  return {
    schemaVersion: 1,
    datasetVersion: getPoiRepository().getDatasetMeta().datasetVersion,
    savedAt: '2026-07-30T10:00:00.000Z',
    payload,
  }
}

Object.assign(uni, {
  request: vi.fn(),
  setStorageSync: vi.fn(),
})

describe('travel preferences', () => {
  it('normalizes set-like fields in a deterministic domain order', () => {
    const result = normalizeTravelPreferences(preferences({
      themes: ['研学', '亲子', '研学'],
      interests: ['摄影', '自然风光'],
      extraRequirements: '  慢一点  ',
    }))

    expect(result.themes).toEqual(['亲子', '研学'])
    expect(result.interests).toEqual(['自然风光', '摄影'])
    expect(result.extraRequirements).toBe('慢一点')
  })

  it('rejects invalid group and child-age combinations', () => {
    expect(() => normalizeTravelPreferences(preferences({ adults: 0, children: 0, childAges: [] })))
      .toThrow('出行总人数至少为 1')
    expect(() => normalizeTravelPreferences(preferences({ children: 2, childAges: [6] })))
      .toThrow('儿童年龄数量必须与儿童人数一致')
    expect(() => normalizeTravelPreferences(preferences({ extraRequirements: 'x'.repeat(501) })))
      .toThrow('不能超过 500 字')
  })
})

describe('planned duration notice', () => {
  it('detects a large gap without exposing the calculated difference', () => {
    expect(hasSignificantDurationGap('half_day', 180)).toBe(true)
    expect(hasSignificantDurationGap('full_day', 360)).toBe(true)
    expect(hasSignificantDurationGap('half_day', 210)).toBe(false)
    expect(hasSignificantDurationGap('full_day', 450)).toBe(false)
  })

  it('ignores invalid planned durations', () => {
    expect(hasSignificantDurationGap('half_day', 0)).toBe(false)
    expect(hasSignificantDurationGap('full_day', Number.NaN)).toBe(false)
  })

  it('classifies explicit minute budgets', () => {
    expect(classifyDurationFit(240, 241)).toBe('overflow')
    expect(classifyDurationFit(240, 180)).toBe('underfilled')
    expect(classifyDurationFit(240, 210)).toBe('fits')
  })
})

describe('planning requests', () => {
  const repository = getPoiRepository()
  const publishedIds = repository.getPublishedPois().map(poi => poi.id)

  function request(overrides: Partial<PlanningRequest> = {}): PlanningRequest {
    return {
      mode: 'quick',
      preferences: preferences(),
      durationMinutes: 240,
      selectedPoiIds: [],
      ...overrides,
    }
  }

  it('enforces duration range and 30-minute steps', () => {
    expect(() => normalizePlanningRequest(request({ durationMinutes: 119 }), repository)).toThrow('120 至 600')
    expect(() => normalizePlanningRequest(request({ durationMinutes: 601 }), repository)).toThrow('120 至 600')
    expect(() => normalizePlanningRequest(request({ durationMinutes: 125 }), repository)).toThrow('30 分钟')
    expect(normalizePlanningRequest(request({ durationMinutes: 120 }), repository).durationMinutes).toBe(120)
    expect(normalizePlanningRequest(request({ durationMinutes: 600 }), repository).durationMinutes).toBe(600)
  })

  it('validates quick and custom selections using only canonical repository IDs', () => {
    expect(() => normalizePlanningRequest(request({ selectedPoiIds: [publishedIds[0]] }), repository))
      .toThrow('快速规划不能预选点位')
    expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: [publishedIds[0]] }), repository))
      .toThrow('2 至 8')
    expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: publishedIds.slice(0, 9) }), repository))
      .toThrow('2 至 8')
    expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: [publishedIds[0], publishedIds[0]] }), repository))
      .toThrow('不能重复')
    expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: ['guangming-hongqiao-park', publishedIds[0]] }), repository))
      .toThrow('ID 无效')
    expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: ['unknown-poi', publishedIds[0]] }), repository))
      .toThrow('ID 无效')
  })
})

describe('canonical POI IDs', () => {
  const repository = getPoiRepository()

  it('keeps canonical IDs and explicitly maps only reviewed legacy IDs', () => {
    expect(resolveCanonicalPoiId('poi_hongqiao_park', repository)).toBe('poi_hongqiao_park')
    expect(resolveCanonicalPoiId('guangming-hongqiao-park', repository)).toBe('poi_hongqiao_park')
    expect(resolveCanonicalPoiId('guangming-science-museum', repository)).toBeNull()
    expect(resolveCanonicalPoiId('虹桥公园', repository)).toBeNull()
    expect(resolveCanonicalPoiId('  ', repository)).toBeNull()
  })

  it('ensures every explicit legacy target exists in PoiRepository', () => {
    Object.values(LEGACY_PLACE_ID_TO_POI_ID).forEach((poiId) => {
      expect(repository.getPoiById(poiId), poiId).not.toBeNull()
    })
  })

  it('canonicalizes legacy IDs and authoritative names in remote responses', () => {
    const plan = createLocalPlan(preferences(), repository)
    const remote = deepClone(plan) as PlanResponse
    const otherItemIds = new Set(remote.itinerary.items.slice(1).map(item => item.placeId))
    const [legacyId, canonicalId] = Object.entries(LEGACY_PLACE_ID_TO_POI_ID)
      .find(([, poiId]) => !otherItemIds.has(poiId))!
    remote.itinerary.items[0].placeId = legacyId
    remote.itinerary.items[0].placeName = '模型编造的名称'
    remote.itinerary.sources[0].placeId = legacyId
    delete (remote as Partial<PlanResponse>).source

    const normalized = normalizePlanResponse(remote, repository, 'remote_ai')

    expect(normalized.itinerary.items[0].placeId).toBe(canonicalId)
    expect(normalized.itinerary.items[0].placeName).toBe(repository.getPoiById(canonicalId)?.name)
    expect(normalized.itinerary.sources[0].placeId).toBe(canonicalId)
    expect(normalized.source).toBe('remote_ai')
  })

  it('safely rejects remote items without a reviewed mapping', () => {
    const remote = deepClone(createLocalPlan(preferences(), repository))
    remote.itinerary.items[0].placeId = 'guangming-science-museum'
    expect(() => normalizePlanResponse(remote, repository, 'remote_ai'))
      .toThrow('无法映射的地点 ID')
  })
})

describe('deterministic local POC planner', () => {
  const repository = getPoiRepository()

  it.each([
    [120, 'relaxed', 1],
    [120, 'moderate', 1],
    [120, 'compact', 2],
    [240, 'relaxed', 3],
    [240, 'moderate', 3],
    [240, 'compact', 3],
    [480, 'relaxed', 4],
    [480, 'moderate', 5],
    [480, 'compact', 6],
    [600, 'relaxed', 5],
    [600, 'moderate', 6],
    [600, 'compact', 7],
  ] as const)('packs %i-minute %s quick plans into %i POIs without overflow', (durationMinutes, pace, expectedCount) => {
    const plan = createLocalPlan({
      mode: 'quick',
      preferences: preferences({ pace: pace as Pace }),
      durationMinutes,
      selectedPoiIds: [],
    }, repository)

    expect(plan.itinerary.items).toHaveLength(expectedCount)
    expect(plan.itinerary.items.length).toBeGreaterThanOrEqual(1)
    expect(plan.itinerary.items.length).toBeLessThanOrEqual(8)
    expect(plan.itinerary.totalMinutes).toBeLessThanOrEqual(durationMinutes)
    expect(plan.itinerary.durationFitStatus).not.toBe('overflow')
  })

  it('rejects a quick plan when even the first stop cannot fit the budget', () => {
    let thrown: unknown
    try {
      createLocalPlan({
        mode: 'quick',
        preferences: preferences({ pace: 'relaxed', transport: 'walking' }),
        durationMinutes: 120,
        selectedPoiIds: [],
        origin: { longitude: 114.3, latitude: 22.3, coordinateSystem: 'GCJ02' },
      }, repository)
    }
    catch (error) {
      thrown = error
    }

    expect(thrown).toBeInstanceOf(TravelValidationError)
    expect(thrown).toMatchObject({ code: 'quick_route_unreachable' })
  })

  it('returns the same route and canonical IDs for equivalent input', () => {
    const first = createLocalPlan(preferences({ themes: ['研学', '亲子'], interests: ['摄影', '自然风光'] }), repository)
    const second = createLocalPlan(preferences({ themes: ['亲子', '研学'], interests: ['自然风光', '摄影'] }), repository)

    expect(second).toEqual(first)
    expect(first.source).toBe('local_poc')
    first.itinerary.items.forEach((item) => {
      expect(repository.getPoiById(item.placeId)).not.toBeNull()
      expect(item.placeName).toBe(repository.getPoiById(item.placeId)?.name)
    })
  })

  it('applies duration, pace and indoor requirements to the structure', () => {
    const halfDay = createLocalPlan(preferences({ duration: 'half_day', pace: 'relaxed' }), repository)
    const fullDay = createLocalPlan(preferences({ duration: 'full_day', pace: 'compact' }), repository)
    const rainyDay = createLocalPlan(preferences({
      duration: 'full_day',
      pace: 'moderate',
      interests: ['文化场馆'],
      extraRequirements: '雨天，只安排室内',
    }), repository)

    expect(halfDay.itinerary.items.length).toBeGreaterThanOrEqual(1)
    expect(halfDay.itinerary.items.length).toBeLessThanOrEqual(8)
    expect(halfDay.itinerary.totalMinutes).toBeLessThanOrEqual(halfDay.itinerary.requestedMinutes)
    expect(fullDay.itinerary.items.length).toBeGreaterThan(halfDay.itinerary.items.length)
    expect(fullDay.itinerary.items.length).toBeLessThanOrEqual(8)
    expect(fullDay.itinerary.totalMinutes).toBeLessThanOrEqual(fullDay.itinerary.requestedMinutes)
    expect(rainyDay.itinerary.items.every(item => repository.getPoiById(item.placeId)?.tagCodes.includes('indoor_venue'))).toBe(true)
  })

  it('keeps times monotonic and includes estimated transfers in total minutes', () => {
    const plan = createLocalPlan(preferences({ duration: 'full_day' }), repository)
    const toMinutes = (clock: string) => {
      const [hours, minutes] = clock.split(':').map(Number)
      return hours * 60 + minutes
    }

    plan.itinerary.items.forEach((item, index) => {
      expect(toMinutes(item.endTime)).toBeGreaterThan(toMinutes(item.startTime))
      if (index > 0) {
        expect(toMinutes(item.startTime)).toBeGreaterThanOrEqual(toMinutes(plan.itinerary.items[index - 1].endTime))
        expect(item.transferFromPreviousMinutes).toBeGreaterThan(0)
      }
    })
    expect(plan.itinerary.totalMinutes).toBe(toMinutes(plan.itinerary.items.at(-1)!.endTime) - 9 * 60)
  })

  it('includes origin-to-first transfer without serializing exact coordinates', () => {
    const baseline = createLocalPlan(preferences({ duration: 'full_day' }), repository)
    const target = repository.getPoiById(baseline.itinerary.items.at(-1)!.placeId)!
    const located = createLocalPlan(
      preferences({ duration: 'full_day' }),
      repository,
      undefined,
      { longitude: target.longitude, latitude: target.latitude, coordinateSystem: 'GCJ02' },
    )

    expect(located.itinerary.items[0].reason).toContain('距你当前位置较近')
    expect(located.itinerary.items[0].transferFromPreviousMinutes).toBeGreaterThan(0)
    expect(located.itinerary.totalMinutes).toBe(
      located.itinerary.items[0].transferFromPreviousMinutes!
      + located.itinerary.items.reduce((total, item) => total
        + (Number(item.endTime.slice(0, 2)) * 60 + Number(item.endTime.slice(3)))
        - (Number(item.startTime.slice(0, 2)) * 60 + Number(item.startTime.slice(3))), 0)
      + located.itinerary.items.slice(1).reduce((total, item) => total + (item.transferFromPreviousMinutes ?? 0), 0),
    )
    expect(located.itinerary.notes.join('')).toContain('精确坐标不会写入行程缓存')
    expect(JSON.stringify(located)).not.toContain(String(target.longitude))
    expect(JSON.stringify(located)).not.toContain(String(target.latitude))
  })

  it('ignores invalid planning origins and keeps the no-location route', () => {
    const baseline = createLocalPlan(preferences(), repository)
    const invalid = createLocalPlan(
      preferences(),
      repository,
      undefined,
      { longitude: 999, latitude: 22.76, coordinateSystem: 'GCJ02' },
    )
    expect(invalid).toEqual(baseline)
  })

  it('supports deterministic text adjustments and avoids false positive negation', () => {
    const initialPreferences = preferences({ duration: 'full_day', pace: 'compact' })
    const initialPlan = createLocalPlan(initialPreferences, repository)
    const slower = adjustLocalPlan(initialPlan, initialPreferences, '节奏慢一点', repository)
    const repeated = adjustLocalPlan(slower.plan, slower.preferences, '节奏慢一点', repository)
    const noIndoor = adjustLocalPlan(initialPlan, initialPreferences, '不要室内，安排户外公园', repository)

    expect(slower.preferences.pace).toBe('relaxed')
    expect(slower.plan.itinerary.items.length).toBeLessThan(initialPlan.itinerary.items.length)
    expect(repeated.plan.itinerary.items.map(item => item.placeId))
      .toEqual(slower.plan.itinerary.items.map(item => item.placeId))
    expect(noIndoor.preferences.interests).not.toContain('文化场馆')
    expect(noIndoor.plan.itinerary.items.every(item => !repository.getPoiById(item.placeId)?.tagCodes.includes('indoor_venue'))).toBe(true)
  })

  it('uses the local planner through the public service when no remote URL exists', async () => {
    const request = vi.spyOn(uni, 'request')
    const plan = await createPlan(preferences(), {
      longitude: 113.94,
      latitude: 22.76,
      coordinateSystem: 'GCJ02',
    })
    expect(plan.source).toBe('local_poc')
    expect(JSON.stringify(plan)).not.toContain('113.94')
    expect(JSON.stringify(plan)).not.toContain('22.76')
    expect(request).not.toHaveBeenCalled()
  })

  it('exposes the exact origin only from the current in-memory planning session', async () => {
    const plan = await createPlan(preferences(), {
      longitude: 113.94,
      latitude: 22.76,
      coordinateSystem: 'GCJ02',
    })

    const first = getSessionPlanningOrigin(plan.conversationId)
    expect(first).toEqual({ longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' })
    if (first)
      first.longitude = 0
    expect(getSessionPlanningOrigin(plan.conversationId)?.longitude).toBe(113.94)
    expect(getSessionPlanningOrigin('unknown-session')).toBeNull()
    expect(JSON.stringify(loadPlanningRequest())).not.toContain('113.94')
  })

  it('preserves all custom selections, reports overflow, and retains them during adjustment', () => {
    const selectedPoiIds = repository.getPublishedPois().slice(0, 8).map(poi => poi.id)
    const request: PlanningRequest = {
      mode: 'custom',
      preferences: preferences({ pace: 'relaxed' }),
      durationMinutes: 120,
      selectedPoiIds,
    }
    const plan = createLocalPlan(request, repository)
    const adjusted = adjustLocalPlan(plan, request.preferences, '节奏慢一点', repository, undefined, request)

    expect(plan.itinerary.items).toHaveLength(8)
    expect(new Set(plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds))
    expect(normalizePlanResponse(plan, repository).itinerary.items).toHaveLength(8)
    expect(plan.itinerary.durationFitStatus).toBe('overflow')
    expect(adjusted.plan.itinerary.planningMode).toBe('custom')
    expect(adjusted.plan.itinerary.requestedMinutes).toBe(120)
    expect(new Set(adjusted.plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds))
  })

  it('caps a large quick plan without overflowing the requested budget', () => {
    const plan = createLocalPlan({
      mode: 'quick',
      preferences: preferences({ pace: 'compact' }),
      durationMinutes: 600,
      selectedPoiIds: [],
    }, repository)
    expect(plan.itinerary.items.length).toBeGreaterThanOrEqual(1)
    expect(plan.itinerary.items.length).toBeLessThanOrEqual(8)
    expect(plan.itinerary.totalMinutes).toBeLessThanOrEqual(plan.itinerary.requestedMinutes)
  })

  it('updates legacy duration metadata when adjustment explicitly changes day length', () => {
    const initialPreferences = preferences({ duration: 'full_day' })
    const initial = createLocalPlan(initialPreferences, repository)
    const adjusted = adjustLocalPlan(initial, initialPreferences, '改成半日游', repository)
    expect(adjusted.preferences.duration).toBe('half_day')
    expect(adjusted.plan.itinerary.requestedMinutes).toBe(240)
  })

  it('accepts eight itinerary items and rejects nine during plan validation', () => {
    const selectedPoiIds = repository.getPublishedPois().slice(0, 8).map(poi => poi.id)
    const plan = createLocalPlan({
      mode: 'custom',
      preferences: preferences(),
      durationMinutes: 600,
      selectedPoiIds,
    }, repository)
    expect(normalizePlanResponse(plan, repository).itinerary.items).toHaveLength(8)

    const invalid = deepClone(plan)
    invalid.itinerary.items.push(deepClone(invalid.itinerary.items[0]))
    expect(() => normalizePlanResponse(invalid, repository)).toThrow('1 至 8')
  })
})

describe('reviewed AI recommendations', () => {
  const repository = getPoiRepository()
  const selectedPoiIds = repository.getPublishedPois().slice(0, 3).map(poi => poi.id)
  const request: PlanningRequest = {
    mode: 'custom',
    preferences: preferences(),
    durationMinutes: 120,
    selectedPoiIds,
    origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' },
  }
  const recommendations = selectedPoiIds.map((poiId, index) => ({
    poiId,
    reason: `AI 白名单推荐理由 ${index + 1}`,
  }))

  it('keeps all custom POIs, AI reasons and local-only location data', () => {
    const plan = createRecommendedPlan(request, recommendations, repository, '真实模型推荐完成', 'remote_ai')

    expect(plan.source).toBe('remote_ai')
    expect(new Set(plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds))
    expect(plan.itinerary.items.every(item => item.reason.includes('AI 白名单推荐理由'))).toBe(true)
    expect(plan.itinerary.durationFitStatus).toBe('overflow')
    expect(JSON.stringify(plan)).not.toContain('113.94')
    expect(JSON.stringify(plan)).not.toContain('22.76')
  })

  it('uses current location to optimize a quick AI recommendation and still returns an overflow route', () => {
    const quickRequest: PlanningRequest = {
      ...request,
      mode: 'quick',
      selectedPoiIds: [],
      durationMinutes: 120,
      origin: { longitude: 0, latitude: 0, coordinateSystem: 'GCJ02' },
    }
    const plan = createRecommendedPlan(quickRequest, recommendations, repository, '推荐完成', 'remote_ai')

    expect(plan.itinerary.items).toHaveLength(1)
    expect(plan.itinerary.durationFitStatus).toBe('overflow')
    expect(plan.itinerary.startsFromCurrentLocation).toBe(true)
  })

  it('labels mock recommendations honestly and rejects an incomplete custom set', () => {
    const mockPlan = createRecommendedPlan(request, recommendations, repository, '演示推荐完成', 'remote_mock')
    expect(mockPlan.source).toBe('remote_mock')
    expect(mockPlan.itinerary.title).not.toContain('AI')
    expect(() => createRecommendedPlan(request, recommendations.slice(0, 2), repository, '', 'remote_ai'))
      .toThrow('未完整保留')
  })
})

describe('travel storage', () => {
  const values = new Map<string, unknown>()

  beforeEach(() => {
    values.clear()
    vi.mocked(uni.getStorageSync).mockImplementation(key => values.get(key))
    vi.mocked(uni.setStorageSync).mockImplementation((key, value) => {
      values.set(key, value)
    })
  })

  it('round-trips validated plans and preferences without sharing references', () => {
    const sourcePreferences = preferences()
    const sourcePlan = createLocalPlan(sourcePreferences, getPoiRepository())
    savePreferences(sourcePreferences)
    savePlan(sourcePlan)

    const firstPreferences = loadPreferences()!
    const firstPlan = loadPlan()!
    firstPreferences.themes.push('研学')
    firstPlan.itinerary.items[0].placeName = '被修改'

    expect(loadPreferences()?.themes).toEqual(['亲子'])
    expect(loadPlan()?.itinerary.items[0].placeName).not.toBe('被修改')
    expect(values.has(PREFERENCES_STORAGE_KEY)).toBe(true)
    expect(values.has(PLAN_STORAGE_KEY)).toBe(true)
  })

  it('round-trips custom planning inputs without persisting an exact origin', () => {
    const selectedPoiIds = getPoiRepository().getPublishedPois().slice(0, 4).map(poi => poi.id)
    const request: PlanningRequest = {
      mode: 'custom',
      preferences: preferences(),
      durationMinutes: 330,
      selectedPoiIds,
      origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' },
    }
    savePlanningRequest(request)

    const loaded = loadPlanningRequest()!
    expect(loaded).toEqual({ ...request, origin: undefined })
    expect(loaded).not.toHaveProperty('origin')
    expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('113.94')
    expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('22.76')
  })

  it('never persists planning-origin coordinates with a plan', () => {
    const sourcePlan = createLocalPlan(
      preferences(),
      getPoiRepository(),
      undefined,
      { longitude: 113.940123, latitude: 22.760456, coordinateSystem: 'GCJ02' },
    )
    savePlan(sourcePlan)
    const serialized = JSON.stringify(values.get(PLAN_STORAGE_KEY))
    expect(serialized).not.toContain('113.940123')
    expect(serialized).not.toContain('22.760456')
  })

  it('returns null for malformed, stale or dangling cached data', () => {
    const plan = createLocalPlan(preferences(), getPoiRepository())
    values.set(PREFERENCES_STORAGE_KEY, [])
    expect(loadPreferences()).toBeNull()

    values.set(PREFERENCES_STORAGE_KEY, storageEnvelope({ ...preferences(), themes: [], interests: [] }))
    expect(loadPreferences()).toBeNull()

    values.set(PLAN_STORAGE_KEY, { ...storageEnvelope(plan), datasetVersion: 'old-dataset' })
    expect(loadPlan()).toBeNull()

    const dangling = deepClone(plan)
    dangling.itinerary.items[0].placeId = 'unknown-place'
    values.set(PLAN_STORAGE_KEY, storageEnvelope(dangling))
    expect(loadPlan()).toBeNull()
  })

  it('does not restore remote plans while AI planning is frozen', () => {
    const plan = createLocalPlan(preferences(), getPoiRepository())
    const remotePlan = { ...plan, source: 'remote_ai' as const }
    values.set(PLAN_STORAGE_KEY, storageEnvelope(remotePlan))

    expect(loadPlan()).toBeNull()
  })

  it('contains storage read failures and reports write failures', () => {
    vi.mocked(uni.getStorageSync).mockImplementation(() => {
      throw new Error('read failed')
    })
    expect(loadPlan()).toBeNull()
    expect(loadPreferences()).toBeNull()

    vi.mocked(uni.setStorageSync).mockImplementation(() => {
      throw new Error('quota exceeded')
    })
    expect(() => savePreferences(preferences())).toThrow('无法保存本机行程数据：quota exceeded')
  })
})
