/**
 * GeoTracker.js — a framework-agnostic GPS live-position + trail-recording
 * engine. It has **no** dependency on OpenLayers, Bootstrap, SQLocal, or any
 * LUPMIS code, so it can be dropped into any web project. Persistence and
 * server sync are provided by the host through small adapter objects.
 *
 * ─────────────────────────────────────────────────────────────────────────
 * STORAGE ADAPTER (required for recording) — all methods may be async:
 *   createTrail(meta)            -> trailId            // meta: {uuid,name,startedAt,...}
 *   addPoint(trailId, point)     -> void               // point: normalized fix (see below)
 *   finishTrail(trailId, summary)-> void               // summary: {endedAt,pointCount,distanceM}
 *   getUnsyncedTrails()          -> Array<trail>        // trails with synced=0 and completed
 *   getTrailPoints(trailId)      -> Array<point>
 *   markTrailSynced(trailId, remoteId) -> void
 *
 * SYNC ADAPTER (optional) — store-and-forward:
 *   pushTrail(trail, points)     -> { remoteId } | throws
 *   isOnline?()                  -> boolean             // optional connectivity probe
 *
 * NORMALIZED FIX shape emitted on 'position' and stored via addPoint:
 *   { lon, lat, accuracy, altitude, altitudeAccuracy, heading, speed,
 *     satellites:null, timestamp }
 *   (satellites is always null on the web Geolocation API — kept for parity
 *    with native builds that can populate it.)
 * ─────────────────────────────────────────────────────────────────────────
 *
 * @module geotracker/GeoTracker
 */

import { haversineMeters } from './geo-utils.js';

/** @typedef {'idle'|'watching'|'recording'} GeoTrackerState */

const DEFAULTS = {
  /** Minimum metres between two recorded trail points. */
  minDistanceM: 5,
  /** Ignore fixes arriving faster than this (throttle, ms). */
  minIntervalMs: 1000,
  /** Record a point at least this often even when stationary (heartbeat, ms). */
  heartbeatMs: 20000,
  /** Drop fixes worse than this horizontal accuracy (metres). 0 = accept all. */
  maxAccuracyM: 50,
  /** navigator.geolocation options. */
  enableHighAccuracy: true,
  timeoutMs: 15000,
  maximumAgeMs: 0,
};

export class GeoTracker {
  /**
   * @param {object} [options]
   * @param {object} [options.storage] storage adapter (see module docs)
   * @param {object} [options.sync] sync adapter (see module docs)
   * @param {Geolocation} [options.geolocation] inject navigator.geolocation (for tests)
   * @param {number} [options.minDistanceM]
   * @param {number} [options.minIntervalMs]
   * @param {number} [options.heartbeatMs]
   * @param {number} [options.maxAccuracyM]
   * @param {boolean} [options.enableHighAccuracy]
   */
  constructor(options = {}) {
    this.opts = { ...DEFAULTS, ...options };
    this.storage = options.storage || null;
    this.sync = options.sync || null;
    this._geo = options.geolocation ||
      (typeof navigator !== 'undefined' ? navigator.geolocation : null);

    /** @type {GeoTrackerState} */
    this._state = 'idle';
    this._watchId = null;
    this._live = false;       // live readout requested
    this._recording = false;  // recording in progress

    this._activeTrailId = null;
    this._activeTrailUuid = null;
    this._lastRecorded = null; // last point actually written {lon,lat,timestamp}
    this._lastRecordedAt = 0;
    this._distanceM = 0;
    this._pointCount = 0;
    this._lastFix = null;      // most recent normalized fix (any quality)

    /** @type {Record<string, Set<Function>>} */
    this._listeners = Object.create(null);
  }

  // ── Events ────────────────────────────────────────────────────────────

  /**
   * Subscribe to an event. Returns an unsubscribe function.
   * Events: 'position' | 'point' | 'statechange' | 'trailstart' |
   *         'trailstop' | 'error' | 'syncstatus'
   * @param {string} event
   * @param {Function} cb
   * @returns {() => void}
   */
  on(event, cb) {
    (this._listeners[event] || (this._listeners[event] = new Set())).add(cb);
    return () => this._listeners[event]?.delete(cb);
  }

  _emit(event, payload) {
    const set = this._listeners[event];
    if (!set) return;
    for (const cb of set) {
      try { cb(payload); } catch (err) { console.error(`[GeoTracker] listener for "${event}" threw`, err); }
    }
  }

  // ── Public state ──────────────────────────────────────────────────────

  /** @returns {GeoTrackerState} */
  get state() { return this._state; }
  get isRecording() { return this._recording; }
  get lastFix() { return this._lastFix; }
  get isSupported() { return !!this._geo; }

  _setState(s) {
    if (this._state === s) return;
    this._state = s;
    this._emit('statechange', s);
  }

  // ── Live readout (watch without recording) ──────────────────────────────

  /**
   * Begin a position watch purely for the live readout (no trail is recorded).
   * Safe to call repeatedly.
   */
  startLive() {
    if (!this._geo) { this._emit('error', new Error('Geolocation not supported')); return; }
    this._live = true;
    this._ensureWatch();
  }

  /** Stop the live readout. Has no effect while a recording is in progress. */
  stopLive() {
    this._live = false;
    if (!this._recording) this._teardownWatch();
  }

  /**
   * One-shot position request (e.g. for a "Locate me" button). Resolves with a
   * normalized fix. Does not start/stop the watch.
   * @returns {Promise<object>}
   */
  getCurrentPosition() {
    return new Promise((resolve, reject) => {
      if (!this._geo) { reject(new Error('Geolocation not supported')); return; }
      this._geo.getCurrentPosition(
        (pos) => {
          const fix = GeoTracker.normalize(pos);
          this._lastFix = fix;
          this._emit('position', fix);
          resolve(fix);
        },
        (err) => { this._emit('error', err); reject(err); },
        {
          enableHighAccuracy: this.opts.enableHighAccuracy,
          timeout: this.opts.timeoutMs,
          maximumAge: this.opts.maximumAgeMs,
        }
      );
    });
  }

  // ── Recording ───────────────────────────────────────────────────────────

  /**
   * Start recording a new trail. Creates the trail in storage, then records
   * filtered points as the device moves.
   * @param {object} [meta] e.g. { name, districtId }
   * @returns {Promise<{trailId:*, uuid:string}>}
   */
  async startRecording(meta = {}) {
    if (!this._geo) throw new Error('Geolocation not supported');
    if (!this.storage) throw new Error('GeoTracker: no storage adapter configured');
    if (this._recording) return { trailId: this._activeTrailId, uuid: this._activeTrailUuid };

    const uuid = GeoTracker.uuid();
    const startedAt = new Date().toISOString();
    const trailMeta = { uuid, name: meta.name || null, startedAt, ...meta };
    const trailId = await this.storage.createTrail(trailMeta);

    this._activeTrailId = trailId;
    this._activeTrailUuid = uuid;
    this._lastRecorded = null;
    this._lastRecordedAt = 0;
    this._distanceM = 0;
    this._pointCount = 0;
    this._recording = true;

    this._ensureWatch();
    this._setState('recording');
    this._emit('trailstart', { trailId, uuid, startedAt });
    return { trailId, uuid };
  }

  /**
   * Stop the active recording, finalise the trail summary, and (if a sync
   * adapter is present) attempt to push it immediately.
   * @returns {Promise<{trailId:*, pointCount:number, distanceM:number, synced:boolean}>}
   */
  async stopRecording() {
    if (!this._recording) return null;
    const trailId = this._activeTrailId;
    const endedAt = new Date().toISOString();
    const summary = { endedAt, pointCount: this._pointCount, distanceM: this._distanceM };

    this._recording = false;
    if (!this._live) this._teardownWatch();
    this._setState(this._live ? 'watching' : 'idle');

    try {
      await this.storage.finishTrail(trailId, summary);
    } catch (err) {
      this._emit('error', err);
    }
    this._emit('trailstop', { trailId, ...summary });

    let synced = false;
    if (this.sync) {
      try { synced = await this._syncTrail(trailId); }
      catch (err) { this._emit('error', err); }
    }

    this._activeTrailId = null;
    this._activeTrailUuid = null;
    return { trailId, pointCount: summary.pointCount, distanceM: summary.distanceM, synced };
  }

  // ── Sync (store-and-forward) ────────────────────────────────────────────

  /**
   * Push all completed-but-unsynced trails to the server via the sync adapter.
   * Call on app start and whenever connectivity returns.
   * @returns {Promise<{pushed:number, failed:number}>}
   */
  async syncPending() {
    if (!this.sync || !this.storage) return { pushed: 0, failed: 0 };
    if (this.sync.isOnline && !this.sync.isOnline()) return { pushed: 0, failed: 0 };

    let pushed = 0, failed = 0;
    const trails = await this.storage.getUnsyncedTrails();
    for (const trail of trails) {
      try {
        const ok = await this._syncTrail(trail.id ?? trail.trailId, trail);
        ok ? pushed++ : failed++;
      } catch (err) {
        failed++;
        this._emit('error', err);
      }
    }
    this._emit('syncstatus', { pushed, failed });
    return { pushed, failed };
  }

  /** @private push a single trail by id. */
  async _syncTrail(trailId, trailRow) {
    const points = await this.storage.getTrailPoints(trailId);
    const trail = trailRow || { id: trailId };
    const result = await this.sync.pushTrail(trail, points);
    const remoteId = result && (result.remoteId ?? result.id ?? null);
    await this.storage.markTrailSynced(trailId, remoteId);
    return true;
  }

  // ── Internal watch handling ──────────────────────────────────────────────

  /** @private start the geolocation watch if not already running. */
  _ensureWatch() {
    if (this._watchId != null || !this._geo) {
      if (this._state === 'idle' && this._live) this._setState('watching');
      return;
    }
    this._watchId = this._geo.watchPosition(
      (pos) => this._onFix(pos),
      (err) => this._emit('error', err),
      {
        enableHighAccuracy: this.opts.enableHighAccuracy,
        timeout: this.opts.timeoutMs,
        maximumAge: this.opts.maximumAgeMs,
      }
    );
    if (!this._recording) this._setState('watching');
  }

  /** @private stop the geolocation watch. */
  _teardownWatch() {
    if (this._watchId != null && this._geo) {
      this._geo.clearWatch(this._watchId);
    }
    this._watchId = null;
  }

  /** @private handle a raw Geolocation fix. */
  async _onFix(pos) {
    const fix = GeoTracker.normalize(pos);
    this._lastFix = fix;
    this._emit('position', fix); // always emit for the live readout

    if (!this._recording) return;

    const { minIntervalMs, minDistanceM, heartbeatMs, maxAccuracyM } = this.opts;
    const now = fix.timestamp;

    // Throttle very frequent fixes.
    if (this._lastRecordedAt && (now - this._lastRecordedAt) < minIntervalMs) return;
    // Drop low-quality fixes (unless this is the very first point).
    if (maxAccuracyM > 0 && fix.accuracy != null && fix.accuracy > maxAccuracyM && this._lastRecorded) return;

    let keep = false;
    let stepM = 0;
    if (!this._lastRecorded) {
      keep = true; // always record the first point
    } else {
      stepM = haversineMeters(this._lastRecorded.lon, this._lastRecorded.lat, fix.lon, fix.lat);
      const elapsed = now - this._lastRecordedAt;
      if (stepM >= minDistanceM || elapsed >= heartbeatMs) keep = true;
    }
    if (!keep) return;

    if (this._lastRecorded) this._distanceM += stepM;
    this._pointCount += 1;
    this._lastRecorded = { lon: fix.lon, lat: fix.lat, timestamp: now };
    this._lastRecordedAt = now;

    try {
      await this.storage.addPoint(this._activeTrailId, { ...fix, seq: this._pointCount });
      this._emit('point', {
        trailId: this._activeTrailId,
        seq: this._pointCount,
        point: fix,
        distanceM: this._distanceM,
        pointCount: this._pointCount,
      });
    } catch (err) {
      this._emit('error', err);
    }
  }

  // ── Static helpers ────────────────────────────────────────────────────────

  /** Normalize a browser GeolocationPosition into the module's fix shape. */
  static normalize(pos) {
    const c = pos.coords || {};
    const num = (v) => (v != null && !Number.isNaN(v) ? v : null);
    return {
      lon: c.longitude,
      lat: c.latitude,
      accuracy: num(c.accuracy),
      altitude: num(c.altitude),
      altitudeAccuracy: num(c.altitudeAccuracy),
      heading: num(c.heading),
      speed: num(c.speed),
      satellites: null, // not exposed by the web Geolocation API
      timestamp: pos.timestamp || Date.now(),
    };
  }

  /** RFC4122-ish UUID, using crypto when available. */
  static uuid() {
    if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
      const r = (Math.random() * 16) | 0;
      const v = ch === 'x' ? r : (r & 0x3) | 0x8;
      return v.toString(16);
    });
  }
}

export default GeoTracker;
