import { BlobStore } from '@civility/blobs'
import { IDBBlobStorage } from '@civility/blobs/idb'
import { client } from '@civility/workers'
import { Store } from '@civility/store'
import { IDBStorage } from '@civility/store/idb'
import { Synced } from '@civility/sync'
import { Shared, type ShareRecord, SHARES_COLLECTION } from '@civility/share'
import {
  type BlobRef,
  Bookmark,
  bookmarkDisplayName,
  BookmarkList,
  type BookmarkProperties,
  LastView,
  SearchHistoryEntry,
  type SharedBookmark,
  type SharedList,
  toSharedBookmark,
} from './schema.ts'

/**
 * Collection holding the published payload for each shared list. Separate
 * from `bookmarkCollections` because a CivShare link exposes exactly one
 * document: the recipient can't follow references into the publisher's other
 * collections, so a shared list has to be a single self-contained doc.
 */
const LIST_SHARES = 'listShares'

/**
 * The same idea for a single location: publishing straight out of `bookmarks`
 * would serve the stored document — other list memberships, geocoder ids,
 * internal timestamps — so shares point at a projection instead.
 */
const BOOKMARK_SHARES = 'bookmarkShares'

/** Every collection whose documents are derived payloads for a live share. */
const SHARE_PAYLOAD_COLLECTIONS = [LIST_SHARES, BOOKMARK_SHARES]

/**
 * Change-detection key for a shared list. Deliberately not a deep compare of
 * the whole doc: a round-tripped copy can differ from a freshly built one in
 * ways that don't matter (an absent optional vs. an explicit `null`), and any
 * such mismatch would make the live refresh rewrite the doc on every store
 * change forever. Membership plus the newest timestamp covers every real
 * edit — renames and colour changes bump the list's `updatedAt`, edits
 * bump a bookmark's, and adds/removes change the membership line.
 *
 * Membership is keyed on position + name rather than bookmark id, since the
 * public projection deliberately carries neither ids nor timestamps.
 */
function listSignature(list: SharedList): string {
  return JSON.stringify([
    list.name,
    list.color ?? null,
    list.icon ?? null,
    list.updatedAt,
    list.bookmarks.map((b) => `${b.coordinates.join(',')}:${b.name}`),
  ])
}

/** The same, for a single published location. */
function bookmarkSignature(b: SharedBookmark): string {
  return JSON.stringify([
    b.name,
    b.coordinates,
    b.address ?? null,
    b.description ?? null,
    b.links ?? null,
    b.visited,
    b.favorite,
    b.zoom,
    b.images.map((i) => i.hash),
  ])
}

type Preferences = {
  geocodingBookmarksEnabled: boolean
  onlineSearchEnabled: boolean
  onlineTilesEnabled: boolean
  trackOnInitialLoad: boolean
  hasSeenWelcome: boolean
  transitLayerEnabled: boolean
}

const defaultPreferences: Preferences = {
  geocodingBookmarksEnabled: true,
  onlineSearchEnabled: true,
  onlineTilesEnabled: true,
  trackOnInitialLoad: false,
  hasSeenWelcome: false,
  transitLayerEnabled: false,
}

export const APP_ID = 'me.bpev.maps'

/**
 * The map viewport (center + zoom) restored on next launch. Deliberately
 * device-local, not synced: `moveend` fires on every pan/zoom/pinch, and
 * routing that through the sync change log — full patch history retained for
 * the standard retention window — turned "where was the map pointed" into
 * the single largest contributor to a user's change-log storage, dwarfing
 * their actual bookmark data. `localStorage` gives the same "resume where I
 * left off" behavior with none of that cost; it just doesn't follow the user
 * to another device.
 */
const LAST_VIEW_KEY = `${APP_ID}.lastView`

const backend = new IDBStorage({ dbName: APP_ID })
/**
 * Blobs get their own IndexedDB database, not a second set of object stores
 * inside the main one.
 *
 * Both backends open at version 1 and create their stores in
 * `onupgradeneeded`, which only fires when the version *increases*. Sharing a
 * database name meant whichever opened first created its stores and the
 * second saw v1 already present, so its stores were never created at all —
 * `blobs.put()` then failed with "One of the specified object stores was not
 * found". Separate names give each backend its own upgrade.
 */
const BLOB_DB = `${APP_ID}.blobs`
const blobBackend = new IDBBlobStorage({ dbName: BLOB_DB })
const blobStore = new BlobStore(blobBackend)
const store = new Store(backend, {
  documents: ['preferences', 'searchHistory'],
  // `_civility.shares` is CivShare's reserved control collection: it syncs like
  // any other, but the server interprets it — a record activates a public
  // share, a tombstone revokes it. Always plaintext (spec §13.3).
  collections: [
    'bookmarks',
    // Bookmark lists. The storage key predates the rename from "collection"
    // to "list" and is deliberately unchanged: it's synced data, so renaming
    // it would strand every device still on the old key.
    'bookmarkCollections',
    LIST_SHARES,
    BOOKMARK_SHARES,
    SHARES_COLLECTION,
  ],
  versions: [{
    version: globalThis.__APP_VERSION__,
    schema: {
      type: 'object',
      properties: {
        preferences: { type: 'object' },
        searchHistory: { type: 'array' },
        bookmarks: { type: 'object' },
        bookmarkCollections: { type: 'object' },
        [LIST_SHARES]: { type: 'object' },
        [BOOKMARK_SHARES]: { type: 'object' },
        [SHARES_COLLECTION]: { type: 'object' },
      },
    },
  }],
})

const bookmarksColl = store.collection<Bookmark>('bookmarks')
const listsColl = store
  .collection<BookmarkList>('bookmarkCollections')
const listSharesColl = store.collection<SharedList>(LIST_SHARES)
const bookmarkSharesColl = store.collection<SharedBookmark>(BOOKMARK_SHARES)
const sharesColl = store.collection<ShareRecord>(SHARES_COLLECTION)
const preferencesDoc = store
  .document<Preferences>('preferences', defaultPreferences)
const searchHistoryDoc = store
  .document<SearchHistoryEntry[]>('searchHistory', [])

export class App {
  #subscribers = new Set<() => void>()
  #shared: Shared | null = null
  #refreshingShares = false
  #refreshSharesQueued = false

  blobs = blobStore

  synced = new Synced({
    stores: [
      bookmarksColl,
      listsColl,
      listSharesColl,
      bookmarkSharesColl,
      sharesColl,
    ],
    appId: APP_ID,
    // Registered with the server on first sync.
    manifest: {
      name: 'Maps App',
      description: 'An offline-capable map application',
      requestedPermissions: [
        { store: 'bookmarks', level: 'delete' },
        { store: 'bookmarkCollections', level: 'delete' },
        { store: LIST_SHARES, level: 'delete' },
        { store: BOOKMARK_SHARES, level: 'delete' },
        { store: SHARES_COLLECTION, level: 'delete' },
      ],
      changeRetentionDays: 60,
    },
    blobStore,
    // Metadata syncs immediately; image bodies download on demand (see
    // `getBlob`), so a phone never eagerly pulls every photo on every device.
    blobPolicy: 'lazy',
  })

  constructor() {
    store.subscribe(() => {
      void this.#refreshShares()
      this.#notify()
    })
    // Keep the worker's blob proxy holding a current token — login, logout,
    // and token rotation all land here.
    this.synced.addEventListener('auth', () => {
      void this.#configureBlobProxy()
    })
    this.#init()
  }

  async #init(): Promise<void> {
    await Promise.all([
      bookmarksColl.preload(),
      listsColl.preload(),
      listSharesColl.preload(),
      bookmarkSharesColl.preload(),
      sharesColl.preload(),
      preferencesDoc.preload(),
      searchHistoryDoc.preload(),
    ])
    await this.#restoreSync()
    await this.#refreshShares()
    this.#notify()
  }

  /**
   * Reconnect a previously-saved sync session (set up in Settings via
   * `<civ-sync-input>`) so data syncs in the background from launch — not
   * only once the user next opens Settings or a share dialog.
   */
  async #restoreSync(): Promise<void> {
    const conn = await this.synced.restoreConnection()
    if (!conn) return
    if (!this.synced.connected) {
      this.synced.connect(conn.url)
      this.synced.setToken(conn.token)
      this.synced.startSync()
    }
    await this.#configureBlobProxy()
  }

  /**
   * Hands the service worker's blob proxy the sync connection, so
   * `/blob/<hash>` requests for bodies left lazy by `blobPolicy: 'lazy'` can
   * fall through to the server with auth attached.
   */
  async #configureBlobProxy(): Promise<void> {
    const conn = await this.synced.restoreConnection()
    if (!conn) return
    await client.configureBlobProxy({
      serverUrl: conn.url,
      token: conn.token,
    })
  }

  #notify(): void {
    for (const cb of this.#subscribers) cb()
  }

  addEventListener(fn: () => void): void {
    this.#subscribers.add(fn)
  }

  removeEventListener(fn: () => void): void {
    this.#subscribers.delete(fn)
  }

  // ====== SEARCH HISTORY ======

  get searchHistory(): SearchHistoryEntry[] {
    return searchHistoryDoc.value ?? []
  }

  async addSearch(query: string): Promise<void> {
    const trimmed = query.trim()
    if (!trimmed) return
    const current = searchHistoryDoc.value ?? []
    const existing = current.filter((e) => e.query !== trimmed)
    await searchHistoryDoc.set([
      SearchHistoryEntry.parse({
        query: trimmed,
        timestamp: new Date().toISOString(),
      }),
      ...existing,
    ].slice(0, 20))
  }

  async clearHistory(): Promise<void> {
    await searchHistoryDoc.set([])
  }

  // ====== BOOKMARKS ======

  /** Raw collection map reference — stable between unrelated store changes. */
  get bookmarkValues(): ReadonlyMap<string, Bookmark> | undefined {
    return bookmarksColl.value
  }

  /** Raw collection map reference — stable between unrelated store changes. */
  get listValues(): ReadonlyMap<string, BookmarkList> | undefined {
    return listsColl.value
  }

  get bookmarks(): Bookmark[] {
    return [...(bookmarksColl.value?.values() ?? [])]
      .sort((a, b) =>
        bookmarkDisplayName(a).localeCompare(bookmarkDisplayName(b))
      )
  }

  get bookmarkLists(): BookmarkList[] {
    return [...(listsColl.value?.values() ?? [])]
      .sort((a, b) => a.name.localeCompare(b.name))
  }

  async addBookmark(
    lat: number,
    lng: number,
    zoom: number,
    properties: BookmarkProperties,
    categories: string[] = [],
  ): Promise<void> {
    const now = new Date().toISOString()
    const id = crypto.randomUUID()
    await bookmarksColl.set(
      id,
      Bookmark.parse({
        id,
        type: 'Feature',
        geometry: { type: 'Point', coordinates: [lng, lat] },
        properties,
        categories,
        zoom,
        createdAt: now,
        updatedAt: now,
      }),
    )
  }

  async deleteBookmark(id: string): Promise<void> {
    // Revoke first so a live link never outlives the bookmark it points at.
    const share = await this.shareForBookmark(id).catch(() => undefined)
    if (share) await this.revokeShare(share.id)
    await bookmarkSharesColl.delete(id)
    await bookmarksColl.delete(id)
  }

  async updateBookmark(
    id: string,
    updates: {
      properties?: Partial<BookmarkProperties>
      geometry?: Bookmark['geometry']
      categories?: string[]
      zoom?: number
      images?: BlobRef[]
      visited?: boolean
      favorite?: boolean
    },
  ): Promise<void> {
    const existing = await bookmarksColl.get(id)
    if (!existing) return
    await bookmarksColl.set(id, {
      ...existing,
      ...(updates.categories !== undefined &&
        { categories: updates.categories }),
      ...(updates.geometry !== undefined && { geometry: updates.geometry }),
      ...(updates.zoom !== undefined && { zoom: updates.zoom }),
      ...(updates.images !== undefined && { images: updates.images }),
      ...(updates.visited !== undefined && { visited: updates.visited }),
      ...(updates.favorite !== undefined && { favorite: updates.favorite }),
      properties: updates.properties
        ? { ...existing.properties, ...updates.properties }
        : existing.properties,
      updatedAt: new Date().toISOString(),
    })
  }

  // ====== LISTS ======

  /** Returns the new list's id. */
  async addList(name: string, color?: string): Promise<string> {
    const now = new Date().toISOString()
    const id = crypto.randomUUID()
    await listsColl.set(
      id,
      BookmarkList.parse({
        id,
        name,
        color,
        createdAt: now,
        updatedAt: now,
      }),
    )
    return id
  }

  async updateList(
    id: string,
    updates: Partial<Pick<BookmarkList, 'name' | 'color' | 'icon'>>,
  ): Promise<void> {
    const existing = await listsColl.get(id)
    if (!existing) return
    await listsColl.set(id, {
      ...existing,
      ...updates,
      updatedAt: new Date().toISOString(),
    })
  }

  async deleteList(id: string): Promise<void> {
    // Revoke first so a live share never serves a list whose source is gone.
    const share = await this.shareForList(id).catch(() => undefined)
    if (share) await this.revokeShare(share.id)
    await listSharesColl.delete(id)
    await listsColl.delete(id)
    const allBookmarks = await bookmarksColl.getAll()
    await Promise.all(
      [...allBookmarks.entries()]
        .filter(([, b]) => b.categories.includes(id))
        .map(([bId, b]) =>
          bookmarksColl.set(bId, {
            ...b,
            categories: b.categories.filter((c) => c !== id),
            updatedAt: new Date().toISOString(),
          })
        ),
    )
  }

  // ====== PREFERENCES ======

  get geocodingBookmarksEnabled(): boolean {
    return preferencesDoc.value?.geocodingBookmarksEnabled ?? true
  }

  async setGeocodingBookmarksEnabled(value: boolean): Promise<void> {
    await preferencesDoc.update({ geocodingBookmarksEnabled: value })
  }

  get onlineSearchEnabled(): boolean {
    return preferencesDoc.value?.onlineSearchEnabled ?? true
  }

  async setOnlineSearchEnabled(value: boolean): Promise<void> {
    await preferencesDoc.update({ onlineSearchEnabled: value })
  }

  /**
   * Whether regional archives may be streamed over the network for regions
   * the user hasn't downloaded. Downloaded regions are always used first, so
   * turning this off only removes detail the app would have fetched.
   */
  get onlineTilesEnabled(): boolean {
    return preferencesDoc.value?.onlineTilesEnabled ?? true
  }

  async setOnlineTilesEnabled(value: boolean): Promise<void> {
    await preferencesDoc.update({ onlineTilesEnabled: value })
  }

  /** Whether the transit route/station overlay renders on top of the base map. */
  get transitLayerEnabled(): boolean {
    return preferencesDoc.value?.transitLayerEnabled ?? false
  }

  async setTransitLayerEnabled(value: boolean): Promise<void> {
    await preferencesDoc.update({ transitLayerEnabled: value })
  }

  get trackOnInitialLoad(): boolean {
    return preferencesDoc.value?.trackOnInitialLoad ?? false
  }

  async setTrackOnInitialLoad(value: boolean): Promise<void> {
    await preferencesDoc.update({ trackOnInitialLoad: value })
  }

  get lastView(): LastView | null {
    try {
      const raw = localStorage.getItem(LAST_VIEW_KEY)
      return raw ? LastView.parse(JSON.parse(raw)) : null
    } catch {
      return null
    }
  }

  setLastView(value: LastView): void {
    try {
      localStorage.setItem(LAST_VIEW_KEY, JSON.stringify(value))
    } catch {
      // Storage disabled or quota exceeded — losing the resume position
      // isn't worth surfacing an error over.
    }
  }

  get preferencesLoaded(): boolean {
    return preferencesDoc.value !== undefined
  }

  get hasSeenWelcome(): boolean {
    return preferencesDoc.value?.hasSeenWelcome ?? false
  }

  async setHasSeenWelcome(value: boolean): Promise<void> {
    await preferencesDoc.update({ hasSeenWelcome: value })
  }

  // ====== SHARING ======

  /**
   * The publisher-side CivShare wrapper, built lazily the first time a share
   * action needs it — by then the user has connected to a sync server in
   * Settings, so `restoreConnection` yields the origin. Returns `null` when no
   * sync session exists yet (the UI then prompts the user to connect first).
   */
  async shared(): Promise<Shared | null> {
    if (this.#shared) return this.#shared
    const conn = await this.synced.restoreConnection()
    if (!conn) return null
    if (!this.synced.connected) {
      this.synced.connect(conn.url)
      this.synced.setToken(conn.token)
      this.synced.startSync()
    }
    this.#shared = new Shared({
      synced: this.synced,
      shares: sharesColl,
      server: new URL(conn.url).origin,
      // Shares render on the app's own domain; `Shared` appends `?id=&server=`
      // to build the public link. Using the live origin keeps dev/prod links
      // pointed at wherever the app is actually served from.
      appShareUrl: globalThis.location.origin,
    })
    return this.#shared
  }

  /** The existing share record for a bookmark, if it's been published. */
  async shareForBookmark(id: string): Promise<ShareRecord | undefined> {
    const shared = await this.shared()
    if (!shared) return undefined
    return (await shared.list())
      .find((r) => r.collection === BOOKMARK_SHARES && r.docId === id)
  }

  /**
   * Synchronous share-status checks for list rendering (a badge per row) —
   * unlike {@link shareForList}/{@link shareForBookmark}, these read straight
   * off the local `sharesColl` snapshot instead of going through `Shared`, so
   * they're safe to call on every render without awaiting a sync session.
   */
  isListShared(id: string): boolean {
    return [...(sharesColl.value?.values() ?? [])]
      .some((r) => r.collection === LIST_SHARES && r.docId === id)
  }

  isBookmarkShared(id: string): boolean {
    return [...(sharesColl.value?.values() ?? [])]
      .some((r) => r.collection === BOOKMARK_SHARES && r.docId === id)
  }

  /**
   * Publish a bookmark as a public cleartext share; returns `{ id, url }`.
   * Publishes the projection rather than the stored document — see
   * {@link BOOKMARK_SHARES}. Images are included: a single-location share
   * serves its referenced blobs from its own scoped public route.
   */
  async publishBookmark(id: string): Promise<{ id: string; url: string }> {
    const shared = await this.shared()
    if (!shared) {
      throw new Error('Connect to a sync server in Settings first.')
    }
    const source = bookmarksColl.value?.get(id)
    if (!source) throw new Error('That bookmark no longer exists.')
    await bookmarkSharesColl.set(
      id,
      toSharedBookmark(source, { includeImages: true }),
    )
    const { id: shareId, url } = await shared.publish({
      collection: BOOKMARK_SHARES,
      docId: id,
    })
    return { id: shareId, url }
  }

  /** Revoke a previously-published share. */
  async revokeShare(id: string): Promise<void> {
    const shared = await this.shared()
    await shared?.revoke(id)
  }

  /** Base public URL for an existing share id (no key fragment; cleartext). */
  async shareUrlFor(id: string): Promise<string | undefined> {
    const shared = await this.shared()
    return shared?.urlFor(id)
  }

  // ====== SHARED LISTS ======

  /** The bookmarks in a list, in the order the list view shows them. */
  bookmarksInList(listId: string): Bookmark[] {
    return this.bookmarks.filter((b) => b.categories.includes(listId))
  }

  /**
   * Assembles the publishable snapshot for a list. `updatedAt` is the
   * newest timestamp across the list and its bookmarks rather than "now", so
   * the result is a pure function of the data — that's what lets
   * {@link #refreshShares} detect "nothing actually changed" and avoid an
   * infinite write/notify loop.
   */
  #buildSharedList(listId: string): SharedList | null {
    const list = listsColl.value?.get(listId)
    if (!list) return null
    const source = this.bookmarksInList(listId)
    // Narrowed to the public projection here, at the single point where
    // private data would otherwise cross into a published document.
    // Not point-free: `map` would pass the index as the options argument.
    const bookmarks = source.map((b) => toSharedBookmark(b))
    // Read off the stored bookmarks — the projection drops timestamps.
    const updatedAt = source.reduce(
      (latest, b) => (b.updatedAt > latest ? b.updatedAt : latest),
      list.updatedAt,
    )
    return {
      id: list.id,
      name: list.name,
      color: list.color,
      icon: list.icon,
      bookmarks,
      createdAt: list.createdAt,
      updatedAt,
    }
  }

  /**
   * Keeps every published payload in step with the data it was derived from.
   * Cleartext shares are served by reference, so rewriting the doc is all it
   * takes for a live link to reflect an edit. Runs on every store change; the
   * re-entrancy guard plus the signature check keep it from looping on its
   * own writes. A change landing while a refresh is mid-flight queues one
   * follow-up pass rather than being dropped — the signature checks make the
   * extra pass a no-op when the in-flight run already covered it.
   *
   * A source deleted out from under a live share leaves the last payload in
   * place rather than serving an empty one — `deleteBookmark`/`deleteList`
   * revoke the share instead.
   */
  async #refreshShares(): Promise<void> {
    if (this.#refreshingShares) {
      this.#refreshSharesQueued = true
      return
    }
    this.#refreshingShares = true
    try {
      do {
        this.#refreshSharesQueued = false
        const records = [...(sharesColl.value?.values() ?? [])]
          .filter((r) => SHARE_PAYLOAD_COLLECTIONS.includes(r.collection))
        for (const record of records) {
          if (record.collection === LIST_SHARES) {
            const next = this.#buildSharedList(record.docId)
            if (!next) continue
            const current = listSharesColl.value?.get(record.docId)
            if (current && listSignature(current) === listSignature(next)) {
              continue
            }
            await listSharesColl.set(record.docId, next)
          } else {
            const source = bookmarksColl.value?.get(record.docId)
            if (!source) continue
            const next = toSharedBookmark(source, { includeImages: true })
            const current = bookmarkSharesColl.value?.get(record.docId)
            if (
              current && bookmarkSignature(current) === bookmarkSignature(next)
            ) continue
            await bookmarkSharesColl.set(record.docId, next)
          }
        }
      } while (this.#refreshSharesQueued)
    } finally {
      this.#refreshingShares = false
    }
  }

  /** The existing share record for a list, if it's been published. */
  async shareForList(listId: string): Promise<ShareRecord | undefined> {
    const shared = await this.shared()
    if (!shared) return undefined
    return (await shared.list())
      .find((r) => r.collection === LIST_SHARES && r.docId === listId)
  }

  /**
   * Publish a list and its locations as one public link. The payload
   * doc is written first so the share has something to serve the moment it's
   * activated.
   *
   * `useCurrentView` bakes the sharer's own map position — `lastView`, kept
   * current by every pan/zoom regardless of which list is on screen — plus
   * the transit-layer preference into the URL as `lat`/`lng`/`zoom`/`transit`,
   * so the recipient opens on that framing instead of the default fit-to-all.
   * Left off (or when there's no `lastView` yet), the link carries no extra
   * params and the viewer falls back to its usual zoomed-out behaviour.
   */
  async publishList(
    listId: string,
    opts?: { useCurrentView?: boolean },
  ): Promise<{ id: string; url: string }> {
    const shared = await this.shared()
    if (!shared) {
      throw new Error('Connect to a sync server in Settings first.')
    }
    const snapshot = this.#buildSharedList(listId)
    if (!snapshot) throw new Error('That list no longer exists.')
    await listSharesColl.set(listId, snapshot)
    const { id, url } = await shared.publish({
      collection: LIST_SHARES,
      docId: listId,
    })
    return { id, url: opts?.useCurrentView ? this.#withViewParams(url) : url }
  }

  /** Appends the sharer's current map position to a share URL — see {@link publishList}. */
  #withViewParams(url: string): string {
    const view = this.lastView
    if (!view) return url
    const u = new URL(url)
    u.searchParams.set('lat', view.lat.toFixed(6))
    u.searchParams.set('lng', view.lng.toFixed(6))
    u.searchParams.set('zoom', String(view.zoom))
    if (!this.transitLayerEnabled) u.searchParams.set('transit', '0')
    return u.toString()
  }

  /**
   * Copy a shared list into this user's own bookmarks under fresh ids,
   * expanding the public projection back into full bookmarks. Everything the
   * projection omits — images, geocoder ids, the publisher's other list
   * memberships — is simply absent rather than carried over.
   *
   * Locations within ~11m (0.0001°, matching the file-import flow in
   * `routes/bookmarks.ts`) of a bookmark the visitor already has are skipped
   * rather than duplicated — checked against the store as it stood when the
   * import started, not against bookmarks this same import has already added.
   */
  async importSharedList(
    list: SharedList,
  ): Promise<{ listId: string; imported: number; skipped: number }> {
    const now = new Date().toISOString()
    const listId = crypto.randomUUID()
    await listsColl.set(
      listId,
      BookmarkList.parse({
        id: listId,
        name: list.name,
        color: list.color,
        icon: list.icon,
        createdAt: now,
        updatedAt: now,
      }),
    )
    const existing = this.bookmarks
    let skipped = 0
    for (const b of list.bookmarks) {
      const isDuplicate = existing.some((e) =>
        Math.abs(e.geometry.coordinates[1] - b.coordinates[1]) < 0.0001 &&
        Math.abs(e.geometry.coordinates[0] - b.coordinates[0]) < 0.0001
      )
      if (isDuplicate) {
        skipped++
        continue
      }
      const id = crypto.randomUUID()
      await bookmarksColl.set(
        id,
        Bookmark.parse({
          id,
          type: 'Feature',
          geometry: { type: 'Point', coordinates: b.coordinates },
          properties: {
            name: b.name,
            description: b.description,
            links: b.links,
            address: b.address ? { displayText: b.address } : undefined,
          },
          categories: [listId],
          zoom: b.zoom,
          visited: b.visited,
          favorite: b.favorite,
          createdAt: now,
          updatedAt: now,
        }),
      )
    }
    return { listId, imported: list.bookmarks.length - skipped, skipped }
  }

  // ====== DATA ======

  async exportStore(filename?: string): Promise<{
    success: boolean
    path: string
    error?: string
  }> {
    try {
      const data = await store.export()
      const name = filename ?? `maps-export_${Date.now()}`
      const blob = new Blob([JSON.stringify(data)], {
        type: 'application/json',
      })
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = `${name}.json`
      a.click()
      URL.revokeObjectURL(url)
      return { success: true, path: `${name}.json` }
    } catch (error) {
      return {
        success: false,
        path: '',
        error: error instanceof Error ? error.message : 'Export failed',
      }
    }
  }

  importStore(options?: {
    mode?: 'replace' | 'extend'
  }): Promise<{
    success: boolean
    path: string
    error?: string
  }> {
    return new Promise((resolve) => {
      const input = document.createElement('input')
      input.type = 'file'
      input.accept = '.json'
      // Dismissing the picker fires `cancel`, not `change`. Without this the
      // promise never settles and civ-store-import's progress spinner (shown
      // before it awaits us) is stranded on screen.
      input.oncancel = () =>
        resolve({ success: false, path: '', error: 'Import cancelled.' })
      input.onchange = async () => {
        const file = input.files?.[0]
        if (!file) {
          resolve({ success: false, path: '', error: 'No file selected' })
          return
        }
        try {
          const data = JSON.parse(await file.text())
          await store.import(data, options)
          resolve({ success: true, path: file.name })
        } catch (error) {
          resolve({
            success: false,
            path: '',
            error: error instanceof Error ? error.message : 'Import failed',
          })
        }
      }
      input.click()
    })
  }

  async deleteAllData(): Promise<{ success: boolean; error?: string }> {
    try {
      // Close open connections before deleting so the DB isn't blocked
      await store.dispose()
      blobBackend.close()

      const deleteDB = (name: string) =>
        new Promise<void>((resolve, reject) => {
          const req = indexedDB.deleteDatabase(name)
          req.onsuccess = () => resolve()
          req.onerror = () => reject(req.error)
          // onblocked fires when another tab still has the DB open;
          // resolve anyway — the deletion will complete once they close.
          req.onblocked = () => resolve()
        })

      await deleteDB(APP_ID)
      await deleteDB(BLOB_DB)
      return { success: true }
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Delete failed',
      }
    }
  }

  dispose(): void {
    this.#shared?.dispose?.()
    this.synced.dispose()
    store.dispose()
  }
}

export default new App()
