/**
 * Offline Tile Downloader
 *
 * Pre-fetches map tiles for a given extent and zoom range so they are stored
 * in the Service Worker's per-host tile cache for offline use.
 *
 * The downloader simply issues `fetch()` calls; the existing SW intercepts
 * them and routes to the right cache bucket. No direct Cache API access is
 * needed here — the SW is the single source of truth for storage.
 *
 * Throttling defaults are conservative to respect tile-server usage policies:
 *   • 2 concurrent requests
 *   • 50 ms inter-batch delay
 *   • Standard browser User-Agent / Referer headers
 *
 * Usage:
 *   const downloader = new OfflineTileDownloader({
 *     baseMap:    'topo',
 *     extent3857: [minX, minY, maxX, maxY],   // EPSG:3857
 *     minZoom:    10,
 *     maxZoom:    15,
 *     onProgress: (s) => console.log(s),
 *   });
 *   await downloader.start();
 *   downloader.cancel();   // any time
 */

// ============================================================================
// Base-map URL templates
// ============================================================================

/**
 * Tile URL templates for base maps that may be downloaded for offline use.
 *
 * The SW recognises these hosts in `getTileCacheName()` and routes them to
 * the matching `tiles-*-vN` cache. If you add a new entry here, also add
 * the host to the SW's classifier or the tiles will not be cached.
 */
export const BASEMAP_TEMPLATES = {
  topo: {
    url: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
    label: 'Topographic',
    maxZoom: 17,
    cacheKey: 'tiles-topo',
  },
  osm: {
    url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
    label: 'OpenStreetMap',
    maxZoom: 19,
    cacheKey: 'tiles-osm',
  },
};

// Approximate bytes per raster tile — used for storage estimates.
export const AVG_TILE_BYTES = 30 * 1024;

// ============================================================================
// Tile coordinate math (Web Mercator XYZ scheme)
// ============================================================================

const ORIGIN_SHIFT = 2 * Math.PI * 6378137 / 2; // 20037508.342789244

/** Convert Web Mercator metres → (lon, lat) in degrees. */
function metersToLonLat(x, y) {
  const lon = (x / ORIGIN_SHIFT) * 180;
  let lat = (y / ORIGIN_SHIFT) * 180;
  lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2);
  return [lon, lat];
}

/** Tile (x, y) in XYZ scheme for a given lon/lat at zoom z. */
function lonLatToTile(lon, lat, z) {
  const n = Math.pow(2, z);
  const x = Math.floor((lon + 180) / 360 * n);
  const latRad = lat * Math.PI / 180;
  const y = Math.floor(
    (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n
  );
  return { x, y };
}

/** Tile range covering an EPSG:3857 extent at a given zoom level. */
export function tileRangeForExtent(extent3857, z) {
  const [minX, minY, maxX, maxY] = extent3857;
  const [minLon, minLat] = metersToLonLat(minX, minY);
  const [maxLon, maxLat] = metersToLonLat(maxX, maxY);

  const tl = lonLatToTile(minLon, maxLat, z); // top-left in XYZ (NW)
  const br = lonLatToTile(maxLon, minLat, z); // bottom-right (SE)

  const n = Math.pow(2, z);
  const minTileX = Math.max(0, Math.min(tl.x, br.x));
  const maxTileX = Math.min(n - 1, Math.max(tl.x, br.x));
  const minTileY = Math.max(0, Math.min(tl.y, br.y));
  const maxTileY = Math.min(n - 1, Math.max(tl.y, br.y));

  return {
    z,
    minX: minTileX, maxX: maxTileX,
    minY: minTileY, maxY: maxTileY,
    count: (maxTileX - minTileX + 1) * (maxTileY - minTileY + 1),
  };
}

/** Total tile count for an extent across a zoom range (inclusive). */
export function countTiles(extent3857, minZ, maxZ) {
  let total = 0;
  for (let z = minZ; z <= maxZ; z++) {
    total += tileRangeForExtent(extent3857, z).count;
  }
  return total;
}

/**
 * Enumerate every tile in an extent across a zoom range.
 * Returns an array of { z, x, y } objects. For very large ranges this can be
 * large — the caller is expected to validate the count first.
 */
export function enumerateTiles(extent3857, minZ, maxZ) {
  const out = [];
  for (let z = minZ; z <= maxZ; z++) {
    const r = tileRangeForExtent(extent3857, z);
    for (let x = r.minX; x <= r.maxX; x++) {
      for (let y = r.minY; y <= r.maxY; y++) {
        out.push({ z, x, y });
      }
    }
  }
  return out;
}

/**
 * Format a tile URL for a given coordinate using a {z}/{x}/{y} template.
 */
export function formatTileUrl(template, { z, x, y }) {
  return template
    .replace('{z}', z)
    .replace('{x}', x)
    .replace('{y}', y);
}

// ============================================================================
// OfflineTileDownloader
// ============================================================================

/**
 * Concurrent, throttled tile downloader. Issues `fetch()` per tile; the
 * service worker handles caching transparently.
 *
 * Events via `onProgress` callback:
 *   { phase: 'running' | 'done' | 'cancelled' | 'error',
 *     done, total, ok, failed, cached,
 *     elapsedMs, etaMs }
 */
export class OfflineTileDownloader {
  constructor({
    baseMap,             // 'topo' | 'osm'
    extent3857,          // [minX, minY, maxX, maxY]
    minZoom,
    maxZoom,
    concurrency = 2,     // OSM ToS-friendly default
    interBatchDelayMs = 50,
    onProgress = () => {},
  }) {
    const tpl = BASEMAP_TEMPLATES[baseMap];
    if (!tpl) throw new Error(`Unknown base map: ${baseMap}`);
    if (maxZoom > tpl.maxZoom) {
      console.warn(`[OfflineTiles] ${baseMap}: maxZoom ${maxZoom} > supported ${tpl.maxZoom}; clamping`);
      maxZoom = tpl.maxZoom;
    }

    this.baseMap          = baseMap;
    this.template         = tpl.url;
    this.extent           = extent3857;
    this.minZoom          = minZoom;
    this.maxZoom          = maxZoom;
    this.concurrency      = Math.max(1, Math.min(concurrency, 6));
    this.interBatchDelayMs = interBatchDelayMs;
    this.onProgress       = onProgress;

    this._abortCtrl       = null;
    this._cancelled       = false;
  }

  /**
   * Begin downloading. Returns a Promise that resolves with the final stats
   * when complete, or when cancelled.
   */
  async start() {
    if (this._abortCtrl) throw new Error('Downloader already started');
    this._abortCtrl = new AbortController();
    this._cancelled = false;

    const tiles = enumerateTiles(this.extent, this.minZoom, this.maxZoom);
    const total = tiles.length;
    const startedAt = Date.now();

    let done = 0, ok = 0, failed = 0, cached = 0;

    const emit = (phase) => {
      const elapsedMs = Date.now() - startedAt;
      const etaMs = done > 0 ? Math.round((elapsedMs / done) * (total - done)) : null;
      this.onProgress({ phase, done, total, ok, failed, cached, elapsedMs, etaMs });
    };

    emit('running');

    // Process in chunks of `concurrency`
    for (let i = 0; i < tiles.length; i += this.concurrency) {
      if (this._cancelled) break;

      const batch = tiles.slice(i, i + this.concurrency);
      await Promise.all(batch.map(async (t) => {
        if (this._cancelled) return;
        const url = formatTileUrl(this.template, t);

        try {
          const res = await fetch(url, {
            signal: this._abortCtrl.signal,
            // Hint the SW that this is a passive prefetch
            cache: 'default',
          });

          if (res.ok) {
            ok++;
            // Detect "served from SW cache" via headers — not reliable across
            // implementations, so we just count all 200s as ok. Reading the body
            // (or cancelling it) lets the browser GC the response promptly.
            if (res.body) res.body.cancel().catch(() => {});
          } else if (res.status === 408) {
            // Our SW returns 408 when offline AND nothing cached. Treat as failed.
            failed++;
          } else {
            failed++;
          }
        } catch (err) {
          if (err.name === 'AbortError') {
            // Cancellation — don't count
          } else {
            failed++;
          }
        }
        done++;
      }));

      emit('running');

      if (this.interBatchDelayMs > 0 && i + this.concurrency < tiles.length) {
        await new Promise((r) => setTimeout(r, this.interBatchDelayMs));
      }
    }

    emit(this._cancelled ? 'cancelled' : 'done');

    return {
      phase:    this._cancelled ? 'cancelled' : 'done',
      done, total, ok, failed, cached,
      elapsedMs: Date.now() - startedAt,
    };
  }

  /**
   * Cancel an in-flight download. Resolves on the next batch boundary.
   */
  cancel() {
    this._cancelled = true;
    if (this._abortCtrl) this._abortCtrl.abort();
  }
}

// ============================================================================
// Predefined extents
// ============================================================================

/**
 * Whole-of-Ghana bounding box in EPSG:3857.
 * Approximate: -3.3°W → 1.2°E, 4.5°N → 11.2°N.
 */
export const GHANA_EXTENT_3857 = (() => {
  const lonLatToMeters = (lon, lat) => {
    const x = lon * ORIGIN_SHIFT / 180;
    const y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180);
    return [x, y * ORIGIN_SHIFT / 180];
  };
  const sw = lonLatToMeters(-3.3, 4.5);
  const ne = lonLatToMeters(1.2, 11.2);
  return [sw[0], sw[1], ne[0], ne[1]];
})();

// Useful for size estimates
export function estimatedSizeBytes(tileCount) {
  return tileCount * AVG_TILE_BYTES;
}
