package com.wytehat.btlogger;

import android.app.Activity;
import android.app.AlertDialog;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.content.DialogInterface;
import android.content.Intent;
import android.graphics.Color;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.view.Gravity;
import android.view.View;
import android.webkit.WebView;
import android.widget.Button;
import android.widget.CompoundButton;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

public class LiveRangeFinderActivity extends Activity implements LocationListener,
        SensorEventListener {
    private TrackerDatabase db;
    private DeviceRecord target;
    private BluetoothLeScanner scanner;
    private ScanCallback scanCallback;
    private LocationManager locationManager;
    private SensorManager sensorManager;
    private Sensor rotationSensor;
    private Location currentLocation;
    private WebView map;
    private TextView stateText, rssiText, distanceText, trendText, headingText, estimateText;
    private Button startButton, stopButton, calibrationButton, tagButton;
    private final Handler handler = new Handler();
    private final RssiKalmanFilter filter = new RssiKalmanFilter();
    private final RangeStateMachine stateMachine = new RangeStateMachine();
    private final SpatialGradientEngine spatial = new SpatialGradientEngine();
    private NativeFeedbackController feedback;
    private final ArrayList<Double> recentRssi = new ArrayList<Double>();
    private final ArrayList<Integer> calibrationSamples = new ArrayList<Integer>();
    private boolean searching, calibrating, mapReady;
    private long lastSignalTime, lastLoggedTime;
    private float heading;
    /** Magnetic-to-true north correction for the current position. */
    private float declination;
    /** Smoothed compass azimuth, kept in sin/cos form to survive the 0/360 wrap. */
    private float headingSin, headingCos;
    private boolean headingPrimed;
    private long lastHeadingMapUpdate;
    private String headingSource = "Compass";
    private double lastRangeDistance;
    private double lastFilteredRssi = -100.0;
    private String lastRangeState = RangeStateMachine.LOST;
    private String sessionId;

    protected void onCreate(Bundle savedState) {
        super.onCreate(savedState);
        db = new TrackerDatabase(this);
        String address = getIntent().getStringExtra("address");
        target = db.get(address);
        double sharedExponent = getSharedPreferences("range_calibration", 0).getFloat("environment_exponent", 0f);
        if (target != null && sharedExponent >= 1.2 && sharedExponent <= 5.0 &&
            Math.abs(target.pathLossExponent - 2.7) < 0.01) target.pathLossExponent = sharedExponent;
        if (target == null) {
            Toast.makeText(this, "Tracked item was not found", Toast.LENGTH_LONG).show();
            finish();
            return;
        }
        feedback = new NativeFeedbackController(this);
        locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
        sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
        rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR);
        buildInterface();
        buildScanCallback();
        loadMap();
        if (target.liveEnabled == 1) {
            handler.postDelayed(new Runnable() {
                public void run() { if (!searching) startSearch(); }
            }, 500L);
        }
    }

    private void buildInterface() {
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);
        root.setBackgroundColor(Color.rgb(245, 247, 250));

        LinearLayout header = new LinearLayout(this);
        header.setGravity(Gravity.CENTER_VERTICAL);
        header.setPadding(dp(6), dp(5), dp(10), dp(5));
        header.setBackgroundColor(Color.rgb(25, 63, 105));
        Button back = new Button(this);
        back.setText("‹"); back.setTextSize(24);
        header.addView(back, new LinearLayout.LayoutParams(dp(48), dp(40)));
        TextView title = new TextView(this);
        title.setText("Live Find: " + target.displayName());
        title.setTextColor(Color.WHITE); title.setTextSize(17); title.setGravity(Gravity.CENTER);
        title.setTypeface(null, android.graphics.Typeface.BOLD);
        header.addView(title, new LinearLayout.LayoutParams(0, dp(40), 1));
        root.addView(header);
        back.setOnClickListener(new View.OnClickListener() {
            public void onClick(View view) { finish(); }
        });

        LinearLayout controls = new LinearLayout(this);
        controls.setOrientation(LinearLayout.HORIZONTAL);
        startButton = button("START LIVE SEARCH");
        stopButton = button("STOP"); stopButton.setEnabled(false);
        calibrationButton = button("CALIBRATE 1 m"); calibrationButton.setEnabled(false);
        controls.addView(startButton, new LinearLayout.LayoutParams(0, dp(40), 1));
        controls.addView(stopButton, new LinearLayout.LayoutParams(0, dp(40), 1));
        controls.addView(calibrationButton, new LinearLayout.LayoutParams(0, dp(40), 1));
        root.addView(controls);
        tagButton = button("✓  I FOUND IT");
        tagButton.setBackgroundColor(Color.rgb(0, 121, 107));
        tagButton.setTextColor(Color.WHITE);
        root.addView(tagButton, new LinearLayout.LayoutParams(-1, dp(40)));

        LinearLayout toggles = new LinearLayout(this);
        toggles.setGravity(Gravity.CENTER);
        Switch audio = new Switch(this); audio.setText("Audio"); audio.setChecked(false);
        Switch haptic = new Switch(this); haptic.setText("Haptic"); haptic.setChecked(true);
        toggles.addView(audio); toggles.addView(haptic);
        root.addView(toggles);
        audio.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            public void onCheckedChanged(CompoundButton button, boolean checked) {
                feedback.setAudioEnabled(checked);
            }
        });
        haptic.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            public void onCheckedChanged(CompoundButton button, boolean checked) {
                feedback.setHapticEnabled(checked);
            }
        });

        LinearLayout telemetry = new LinearLayout(this);
        telemetry.setOrientation(LinearLayout.VERTICAL);
        telemetry.setPadding(dp(10), dp(3), dp(10), dp(3));
        stateText = label("READY — hold the phone at chest height");
        stateText.setTextSize(17); stateText.setTypeface(null, android.graphics.Typeface.BOLD);
        rssiText = label("RSSI: waiting");
        distanceText = label("Approximate range: waiting");
        trendText = label("Trend: waiting");
        headingText = label("Heading: waiting for compass");
        estimateText = label("Collecting 4 readings per station • walk 8–15 m between stations");
        telemetry.addView(stateText); telemetry.addView(rssiText); telemetry.addView(estimateText);
        root.addView(telemetry);

        map = new WebView(this);
        map.getSettings().setJavaScriptEnabled(true);
        root.addView(map, new LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT, 0, 1));
        setContentView(root);
        LargeScreenLayout.constrain(this);

        startButton.setOnClickListener(new View.OnClickListener() {
            public void onClick(View view) { startSearch(); }
        });
        stopButton.setOnClickListener(new View.OnClickListener() {
            public void onClick(View view) { db.setLiveEnabled(target.address, false); stopSearch("Search stopped"); }
        });
        tagButton.setOnClickListener(new View.OnClickListener() {
            public void onClick(View view) { tagItemHere(); }
        });
        calibrationButton.setOnClickListener(new View.OnClickListener() {
            public void onClick(View view) { startCalibration(); }
        });
    }

    private Button button(String text) {
        Button value = new Button(this); value.setText(text); value.setTextSize(9); return value;
    }
    private TextView label(String text) {
        TextView value = new TextView(this); value.setText(text); value.setTextSize(12);
        value.setTextColor(Color.rgb(35, 45, 55)); value.setPadding(0, dp(1), 0, dp(1));
        return value;
    }

    private void buildScanCallback() {
        if (Build.VERSION.SDK_INT < 21) return;
        scanCallback = new ScanCallback() {
            public void onScanResult(int callbackType, ScanResult result) {
                if (result == null || result.getDevice() == null) return;
                String address;
                try { address = result.getDevice().getAddress(); }
                catch (Exception error) { return; }
                if (target.address.equalsIgnoreCase(address)) processRssi(result.getRssi());
            }
            public void onBatchScanResults(List<ScanResult> results) {
                for (int i = 0; i < results.size(); i++) onScanResult(0, results.get(i));
            }
            public void onScanFailed(int errorCode) {
                Toast.makeText(LiveRangeFinderActivity.this,
                    "BLE scan failed: " + errorCode, Toast.LENGTH_LONG).show();
                stopSearch("Scanner unavailable");
            }
        };
    }

    private void startSearch() {
        if (Build.VERSION.SDK_INT < 21) {
            Toast.makeText(this, "Live BLE ranging requires Android 5 or newer",
                Toast.LENGTH_LONG).show(); return;
        }
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
        if (adapter == null || !adapter.isEnabled()) {
            Toast.makeText(this, "Turn Bluetooth on first", Toast.LENGTH_LONG).show(); return;
        }
        scanner = adapter.getBluetoothLeScanner();
        if (scanner == null) {
            Toast.makeText(this, "BLE scanner is unavailable", Toast.LENGTH_LONG).show(); return;
        }
        filter.reset(); stateMachine.signalLost(); spatial.clear(); recentRssi.clear(); lastRangeDistance = 0; lastFilteredRssi = -100.0; lastRangeState = RangeStateMachine.LOST;
        sessionId = target.address.replace(":", "") + "-" + System.currentTimeMillis();
        try {
            ArrayList<ScanFilter> filters = new ArrayList<ScanFilter>();
            filters.add(new ScanFilter.Builder().setDeviceAddress(target.address).build());
            ScanSettings settings = new ScanSettings.Builder()
                .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build();
            scanner.startScan(filters, settings, scanCallback);
            searching = true; lastSignalTime = System.currentTimeMillis();
            startButton.setEnabled(false); stopButton.setEnabled(true);
            calibrationButton.setEnabled(true);
            stateText.setText("SEARCHING — move slowly and watch the trend");
            requestLocation(); registerSensors(); handler.post(signalWatchdog);
            sendBroadcast(new Intent(BluetoothTrackingService.ACTION_RANGE_MODE)
                .putExtra("active", true).setPackage(getPackageName()));
        } catch (Exception error) {
            Toast.makeText(this, "Bluetooth scan permission or target address unavailable",
                Toast.LENGTH_LONG).show();
        }
    }

    private void stopSearch(String message) {
        searching = false; calibrating = false;
        handler.removeCallbacks(signalWatchdog); handler.removeCallbacks(finishCalibration);
        try { if (scanner != null && scanCallback != null) scanner.stopScan(scanCallback); }
        catch (Exception ignored) { }
        try { locationManager.removeUpdates(this); } catch (Exception ignored) { }
        try { sensorManager.unregisterListener(this); } catch (Exception ignored) { }
        feedback.stop(); stateMachine.signalLost();
        if (stateText != null) { stateText.setText(message); stateText.setTextColor(Color.DKGRAY); }
        if (startButton != null) startButton.setEnabled(true);
        if (stopButton != null) stopButton.setEnabled(false);
        if (calibrationButton != null) calibrationButton.setEnabled(false);
        sendBroadcast(new Intent(BluetoothTrackingService.ACTION_RANGE_MODE)
            .putExtra("active", false).setPackage(getPackageName()));
    }

    private void processRssi(final int rawRssi) {
        if (!searching || rawRssi == 0 || rawRssi == 127 || rawRssi > 0 || rawRssi < -127) return;
        runOnUiThread(new Runnable() {
            public void run() {
                lastSignalTime = System.currentTimeMillis();
                double filtered = filter.update(rawRssi);
                String state = stateMachine.update(filtered);
                double distance = Math.pow(10.0, (target.txPowerAtOneMeter - filtered) /
                    (10.0 * target.pathLossExponent)); lastRangeDistance = distance; lastFilteredRssi = filtered; lastRangeState = state;
                feedback.update(state, filtered);
                addRecent(filtered);
                updateTelemetry(rawRssi, filtered, distance, state);
                if (calibrating) calibrationSamples.add(Integer.valueOf(rawRssi));
                if (currentLocation != null) {
                    long now = System.currentTimeMillis();
                    boolean accepted = spatial.addSample(currentLocation.getLatitude(),
                        currentLocation.getLongitude(), currentLocation.hasAccuracy()
                            ? currentLocation.getAccuracy() : 50, filtered,
                        target.txPowerAtOneMeter, target.pathLossExponent, now);
                    if (now - lastLoggedTime >= 500) {
                        db.logRangeSample(target.address, sessionId, now,
                            currentLocation.getLatitude(), currentLocation.getLongitude(),
                            currentLocation.hasAccuracy() ? currentLocation.getAccuracy() : 0,
                            rawRssi, filtered, heading, state);
                        lastLoggedTime = now;
                    }
                    updateMap(currentLocation, heading, state, distance, accepted);
                }
            }
        });
    }

    private void updateTelemetry(int raw, double filtered, double distance, String state) {
        stateText.setText(state + " — " + instruction(state));
        stateText.setTextColor(colorFor(state));
        rssiText.setText(String.format(Locale.US,
            "Signal %.1f dBm  •  ~%.1f m  •  %s  •  %.0f°",
            filtered, distance, trend(), heading));
    }
    private String instruction(String state) {
        if (RangeStateMachine.HOT.equals(state)) return "very strong; search nearby carefully";
        if (RangeStateMachine.WARM.equals(state)) return "keep walking toward stronger readings";
        if (RangeStateMachine.COLD.equals(state)) return "weak signal; make a wider sweep";
        return "waiting for target advertisements";
    }

    private int colorFor(String state) {
        if (RangeStateMachine.HOT.equals(state)) return Color.rgb(255, 59, 48);
        if (RangeStateMachine.WARM.equals(state)) return Color.rgb(255, 149, 0);
        if (RangeStateMachine.COLD.equals(state)) return Color.rgb(0, 122, 255);
        return Color.DKGRAY;
    }

    private void addRecent(double value) {
        recentRssi.add(Double.valueOf(value));
        while (recentRssi.size() > 8) recentRssi.remove(0);
    }

    private String trend() {
        if (recentRssi.size() < 5) return "collecting samples";
        double change = recentRssi.get(recentRssi.size() - 1).doubleValue() -
            recentRssi.get(0).doubleValue();
        if (change > 2.5) return "APPROACHING ↑";
        if (change < -2.5) return "MOVING AWAY ↓";
        return "STABLE ↔";
    }

    private void tagItemHere() {
        if (currentLocation == null) {
            Toast.makeText(this, "Wait for a GPS fix, then stand beside the item", Toast.LENGTH_LONG).show();
            return;
        }
        final float accuracy = currentLocation.hasAccuracy() ? currentLocation.getAccuracy() : 99f;
        new AlertDialog.Builder(this).setTitle("Mark item as found?")
            .setMessage("Stand directly beside the physical item. The phone GPS coordinate will be saved as the found location. Current GPS accuracy: ±" + Math.round(accuracy) + " m.")
            .setPositiveButton("FOUND IT", new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int which) {
                    long now = System.currentTimeMillis();
                    db.updateLocation(target.address, new TrackerDatabase.Fix(currentLocation.getLatitude(),
                        currentLocation.getLongitude(), accuracy), now);
                    db.setLocationLocked(target.address, true); db.setShowTrail(target.address, false); db.setMapEnabled(target.address, true); db.setLiveEnabled(target.address, false);
                    target.latitude = currentLocation.getLatitude(); target.longitude = currentLocation.getLongitude();
                    target.accuracy = accuracy; target.hasLocation = true; target.locationLocked = 1;
                    double exponent = spatial.calibratePathLoss(target.latitude, target.longitude,
                        target.txPowerAtOneMeter);
                    if (!Double.isNaN(exponent)) {
                        target.pathLossExponent = exponent;
                        db.updateCalibration(target.address, target.txPowerAtOneMeter, exponent);
                        getSharedPreferences("range_calibration", 0).edit()
                            .putFloat("environment_exponent", (float)exponent).apply();
                    }
                    js(String.format(Locale.US, "lockTarget(%.7f,%.7f,%.1f)",
                        target.latitude, target.longitude, target.accuracy));
                    estimateText.setText("ITEM FOUND AT THIS GPS • ±" + Math.round(accuracy) + " m");
                    Toast.makeText(LiveRangeFinderActivity.this, "Found location saved", Toast.LENGTH_LONG).show();
                    stopSearch("Item found"); finish();
                }
            }).setNegativeButton("CANCEL", null).show();
    }

    private void startCalibration() {
        if (!searching) return;
        calibrationSamples.clear(); calibrating = true;
        calibrationButton.setEnabled(false);
        new AlertDialog.Builder(this).setTitle("One-meter calibration")
            .setMessage("Place the phone about one meter from the item, hold it steady at " +
                "chest height, and keep the same orientation for 10 seconds.")
            .setPositiveButton("BEGIN", new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int which) {
                    calibrationSamples.clear(); calibrating = true;
                    stateText.setText("CALIBRATING — remain still for 10 seconds");
                    handler.postDelayed(finishCalibration, 10000);
                }
            }).setNegativeButton("CANCEL", new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int which) {
                    calibrating = false; calibrationButton.setEnabled(true);
                }
            }).show();
    }

    private final Runnable finishCalibration = new Runnable() {
        public void run() {
            calibrating = false; calibrationButton.setEnabled(searching);
            if (calibrationSamples.size() < 5) {
                Toast.makeText(LiveRangeFinderActivity.this,
                    "Not enough target advertisements for calibration", Toast.LENGTH_LONG).show();
                return;
            }
            Collections.sort(calibrationSamples);
            int median = calibrationSamples.get(calibrationSamples.size() / 2).intValue();
            target.txPowerAtOneMeter = median;
            db.updateCalibration(target.address, median, target.pathLossExponent);
            Toast.makeText(LiveRangeFinderActivity.this,
                "Saved one-meter calibration: " + median + " dBm", Toast.LENGTH_LONG).show();
        }
    };

    private final Runnable signalWatchdog = new Runnable() {
        public void run() {
            if (!searching) return;
            if (System.currentTimeMillis() - lastSignalTime > 5000) {
                stateMachine.signalLost(); feedback.stop();
                stateText.setText("SIGNAL LOST — target may not be advertising");
                stateText.setTextColor(Color.GRAY);
                js("setLost()");
            }
            handler.postDelayed(this, 1000);
        }
    };

    private void requestLocation() {
        try {
            Location gps = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
            Location network = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
            currentLocation = gps == null ? network : gps;
            locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000, 1f, this);
            locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 1000, 1f, this);
        } catch (Exception error) {
            Toast.makeText(this, "Location permission is required for walking guidance",
                Toast.LENGTH_LONG).show();
        }
    }

    private void registerSensors() {
        if (rotationSensor != null)
            sensorManager.registerListener(this, rotationSensor, SensorManager.SENSOR_DELAY_GAME);
    }

    public void onLocationChanged(Location location) {
        currentLocation = location;
        declination = new android.hardware.GeomagneticField(
            (float) location.getLatitude(), (float) location.getLongitude(),
            (float) location.getAltitude(), System.currentTimeMillis()).getDeclination();
        if (location.hasBearing() && location.hasSpeed() && location.getSpeed() > 1.0f) {
            heading = location.getBearing(); headingSource = "GPS course";
            headingPrimed = false;
        }
        if (searching) updateMap(location, heading, lastRangeState, lastRangeDistance, false);
    }
    public void onProviderDisabled(String provider) { }
    public void onProviderEnabled(String provider) { }
    public void onStatusChanged(String provider, int status, Bundle extras) { }

    public void onSensorChanged(SensorEvent event) {
        if (event.sensor.getType() != Sensor.TYPE_ROTATION_VECTOR) return;
        float[] rotation = new float[9];
        float[] orientation = new float[3];
        SensorManager.getRotationMatrixFromVector(rotation, event.values);
        SensorManager.getOrientation(rotation, orientation);
        float compass = (float) Math.toDegrees(orientation[0]) + declination;
        while (compass < 0) compass += 360;
        while (compass >= 360) compass -= 360;
        if (currentLocation == null || !currentLocation.hasSpeed() || currentLocation.getSpeed() <= 1) {
            heading = smoothHeading(compass);
            headingSource = "compass";
            long now = System.currentTimeMillis();
            /*
             * The arrow must follow the phone whether or not a search is
             * running - it is the user's own facing, not a search result.
             */
            if (mapReady && now - lastHeadingMapUpdate >= 100L) {
                lastHeadingMapUpdate = now;
                js(String.format(Locale.US, "updateHeading(%.1f)", heading));
                headingText.setText(String.format(Locale.US, "Heading: %.0f° (%s)", heading, headingSource));
            }
        }
    }

    /**
     * Averages the azimuth as a unit vector so the 359 -> 0 wrap does not
     * swing the arrow the long way round.
     */
    private float smoothHeading(float degrees) {
        double radians = Math.toRadians(degrees);
        float sin = (float) Math.sin(radians), cos = (float) Math.cos(radians);
        if (!headingPrimed) { headingSin = sin; headingCos = cos; headingPrimed = true; }
        else {
            headingSin += (sin - headingSin) * 0.25f;
            headingCos += (cos - headingCos) * 0.25f;
        }
        float smoothed = (float) Math.toDegrees(Math.atan2(headingSin, headingCos));
        return smoothed < 0 ? smoothed + 360 : smoothed;
    }

    public void onAccuracyChanged(Sensor sensor, int accuracy) { }

    private void loadMap() {
        map.loadDataWithBaseURL("https://www.openstreetmap.org/", mapHtml(),
            "text/html", "UTF-8", null);
        handler.postDelayed(new Runnable() {
            public void run() { mapReady = true; if (currentLocation != null)
                updateMap(currentLocation, heading, RangeStateMachine.LOST, 0, false); }
        }, 1200);
    }

    private void updateMap(Location location, float direction, String state,
                           double distance, boolean newStation) {
        if (!mapReady || location == null) return;
        String command = String.format(Locale.US,
            "updateUser(%.7f,%.7f,%.1f,'%s',%.2f,%s,%.1f)", location.getLatitude(),
            location.getLongitude(), direction, state, distance,
            newStation ? "true" : "false", lastFilteredRssi);
        js(command);
        if (target.locationLocked == 1 && target.hasLocation) {
            js(String.format(Locale.US, "lockTarget(%.7f,%.7f,%.1f)",
                target.latitude, target.longitude, target.accuracy));
            estimateText.setText("ITEM FOUND AT THIS GPS • ±" + Math.round(target.accuracy) + " m");
            return;
        }
        SpatialGradientEngine.Estimate estimate = spatial.consensusEstimate();
        if (estimate != null) {
            estimateText.setText(String.format(Locale.US,
                "%s • %d stations • ±%.1f m • %s",
                estimate.preliminary ? "Estimate" : "Solved",
                estimate.sampleCount, estimate.confidenceMeters,
                Float.isNaN(estimate.gradientBearing)
                    ? "side unresolved — turn 90° and walk"
                    : String.format(Locale.US, "%.0f°", estimate.gradientBearing)));
            js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,%s)",
                estimate.latitude, estimate.longitude, estimate.confidenceMeters,
                estimate.gradientBearing, estimate.preliminary ? "true" : "false"));
            js(String.format(Locale.US, "updateGradient(%.1f,'%s')", estimate.gradientBearing, state));
        } else {
            SpatialGradientEngine.Estimate hint = spatial.preliminaryEstimate();
            if (hint != null) {
                js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,%.1f,true)",
                    hint.latitude, hint.longitude, hint.confidenceMeters, hint.gradientBearing));
                js(String.format(Locale.US, "updateGradient(%.1f,'%s')", hint.gradientBearing, state));
                estimateText.setText("Likely " + sector(hint.gradientBearing) + " • " + spatial.size() + " stations • refining");
            } else {
                showLastKnownPin();
                estimateText.setText(spatial.size() +
                    " stations • Walk 10 m, then turn 90° and walk again");
            }
        }
    }

    /**
     * With no solved estimate the only honest target is the item's last known
     * GPS fix, if it has one at all.
     *
     * The bearing arrow reports which way the phone is facing and nothing
     * else - it is never a pointer at the item. Projecting the pin along that
     * heading (which this used to do) parks the item wherever the user happens
     * to be aiming, so the arrow always appears to point straight at it and
     * the pin swings around with the compass. Show nothing rather than that.
     */
    private void showLastKnownPin() {
        if (target.hasLocation) {
            js(String.format(Locale.US, "updateEstimate(%.7f,%.7f,%.1f,0,true)",
                target.latitude, target.longitude, Math.max(1.5f, target.accuracy)));
        } else {
            js("clearTarget()");
        }
    }

    private String sector(float bearing) {
        String[] names = { "NORTH", "NORTHEAST", "EAST", "SOUTHEAST",
            "SOUTH", "SOUTHWEST", "WEST", "NORTHWEST" };
        int index = ((int)Math.floor((bearing + 22.5f) / 45.0f)) % 8;
        return names[index];
    }

    private void js(String command) {
        if (mapReady) map.loadUrl("javascript:" + command);
    }

    private String mapHtml() {
        return "<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>" +
            "<link rel='stylesheet' href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'>" +
            "<style>html,body,#map{height:100%;margin:0}.bearing{position:relative;width:42px;height:42px;transform-origin:21px 21px}" +
            ".you{position:absolute;left:10px;top:10px;width:18px;height:18px;border-radius:50%;background:#4285F4;border:3px solid white;box-shadow:0 1px 4px #555;transition:background-color .8s linear}" +
            ".tip{position:absolute;left:17px;top:1px;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:10px solid #4285F4;transition:border-bottom-color .8s linear}" +
            ".item-pin{width:25px;height:25px;background:#E53935;border:3px solid white;border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 2px 5px #555}" +
            ".item-pin:after{content:'';position:absolute;width:8px;height:8px;border-radius:50%;background:#A61F1F;left:8px;top:8px}</style></head>" +
            "<body><div id='map'></div><script src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'></script><script>" +
            "var streets=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxNativeZoom:19,maxZoom:22,attribution:'© OpenStreetMap contributors'})," +
            "sat=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{maxNativeZoom:19,maxZoom:22,attribution:'Esri World Imagery'})," +
            "map=L.map('map',{layers:[streets]}).setView([20,0],2);map.createPane('rangePane');map.getPane('rangePane').style.zIndex=690;map.getPane('rangePane').style.pointerEvents='none';" +
            "L.control.layers({'Streets':streets,'Satellite':sat},null,{collapsed:false}).addTo(map);" +
            "var user=null,target=null,searchCircle=null,liveState='LOST',liveDistance=20,hotCount=0,radiusTarget=20,radiusFrame=0;" +
            "var userIcon=L.divIcon({className:'',html:'<div id=bearing class=bearing><div class=you></div><div class=tip></div></div>',iconSize:[42,42],iconAnchor:[21,21]});" +
            "var pinIcon=L.divIcon({className:'',html:'<div class=item-pin></div>',iconSize:[31,40],iconAnchor:[15,39]});" +
            "function updateUser(a,b,h,s,d,station,signal){var p=[a,b];liveState=s;if(d>0)liveDistance=d;if(s=='HOT'&&d>0&&d<2.5)hotCount++;else if(s!='HOT')hotCount=0;" +
            "if(!user){user=L.marker(p,{icon:userIcon,zIndexOffset:3000}).addTo(map);searchCircle=L.circle(p,{pane:'rangePane',interactive:false,radius:Math.max(.6,liveDistance),color:'#4285F4',weight:2,fillColor:'#4285F4',fillOpacity:.16}).addTo(map);map.setView(p,21);}else user.setLatLng(p);" +
            "searchCircle.setLatLng(p);smoothRadius(Math.max(.6,liveDistance));searchCircle.setStyle({color:'#4285F4',fillColor:'#4285F4',fillOpacity:.16});map.panTo(p,{animate:true});updateHeading(h);updatePhoneColor(signal)}" +
            "function smoothRadius(next){radiusTarget=Math.max(.6,next);if(radiusFrame)return;function step(){if(!searchCircle){radiusFrame=0;return;}var current=searchCircle.getRadius(),change=(radiusTarget-current)*.025;if(Math.abs(radiusTarget-current)<.05){searchCircle.setRadius(radiusTarget);radiusFrame=0;return;}searchCircle.setRadius(current+change);radiusFrame=requestAnimationFrame(step);}radiusFrame=requestAnimationFrame(step);}" +
            "function mix(a,b,t){return Math.round(a+(b-a)*Math.max(0,Math.min(1,t)));}function phoneColor(r){var a,b,t;if(r<=-80)return '#007AFF';if(r<-70){a=[0,122,255];b=[255,214,10];t=(r+80)/10;}else if(r<-60){a=[255,214,10];b=[255,59,48];t=(r+70)/10;}else return '#FF3B30';return 'rgb('+mix(a[0],b[0],t)+','+mix(a[1],b[1],t)+','+mix(a[2],b[2],t)+')';}function updatePhoneColor(r){var c=phoneColor(r),dot=document.querySelector('.you'),tip=document.querySelector('.tip');if(dot)dot.style.backgroundColor=c;if(tip)tip.style.borderBottomColor=c;}" +
            "function updateHeading(h){var e=document.getElementById('bearing');if(e)e.style.transform='rotate('+h+'deg)';}" +
            "function updateEstimate(a,b,r,g,pre){var p=[a,b];if(!target)target=L.marker(p,{icon:pinIcon,zIndexOffset:500}).addTo(map);else target.setLatLng(p);target.bindPopup(pre?'Approximate item location':'Calculated item location');}" +
            "function lockTarget(a,b,r){var p=[a,b];if(!target)target=L.marker(p,{icon:pinIcon,zIndexOffset:500}).addTo(map);else target.setLatLng(p)}" +
            "function clearTarget(){if(target){map.removeLayer(target);target=null;}}" +
            "function updateGradient(g,s){}function setLost(){if(searchCircle)searchCircle.setStyle({color:'#90A4AE',fillColor:'#90A4AE',fillOpacity:.10});}" +
            "</script></body></html>";
    }

    private int dp(int value) {
        return (int) (value * getResources().getDisplayMetrics().density + 0.5f);
    }

    protected void onPause() {
        if (searching) stopSearch("Search paused when screen left foreground");
        super.onPause();
    }

    protected void onDestroy() {
        stopSearch("Search closed");
        feedback.release();
        handler.removeCallbacksAndMessages(null);
        if (map != null) map.destroy();
        db.close();
        super.onDestroy();
    }
}
