package com.wytehat.btlogger;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;

/**
 * GPS history cleanup for the map screen.
 *
 * Recorded history is noisy: the same fix gets logged twice, two fixes claim
 * the same timestamp from different places, and a bad fix occasionally puts a
 * device a kilometre away and back within a second. This walks a device's
 * history in time order and drops the points that cannot be real, keeping a
 * Decision for every removal so the map and the CSV export can show what was
 * taken out and why.
 *
 * One aggressiveness dial (0 = very light, 100 = very aggressive) drives every
 * threshold; see the getters below for what each end of the range means.
 *
 * Split out of MapActivity, which owns the UI, not this.
 */
public class HistoryNormalizer {

    public static final int DEFAULT_AGGRESSIVENESS = 50;

    private static final double MIN_MAX_SPEED_KMH = 20.0;
    private static final double MAX_MAX_SPEED_KMH = 250.0;

    private static final double MIN_DUPLICATE_DISTANCE_METERS = 0.75;
    private static final double MAX_DUPLICATE_DISTANCE_METERS = 5.0;

    private static final double MIN_SAME_TIME_DISTANCE_METERS = 10.0;
    private static final double MAX_SAME_TIME_DISTANCE_METERS = 50.0;

    private static final double MIN_ACCURACY_MULTIPLIER = 0.75;
    private static final double MAX_ACCURACY_MULTIPLIER = 3.0;

    private static final double EARTH_RADIUS_METERS = 6371008.8;

    /** A point that normalization removed, and the reasoning behind it. */
    public static class Decision {

        public LocationPoint point;
        public LocationPoint previousAccepted;

        public String reason;

        public double distanceMeters;
        public double elapsedSeconds;
        public double speedKmh;
        public double threshold;

        public Decision(
                LocationPoint point,
                LocationPoint previousAccepted,
                String reason,
                double distanceMeters,
                double elapsedSeconds,
                double speedKmh,
                double threshold) {

            this.point = point;
            this.previousAccepted = previousAccepted;
            this.reason = reason;
            this.distanceMeters = distanceMeters;
            this.elapsedSeconds = elapsedSeconds;
            this.speedKmh = speedKmh;
            this.threshold = threshold;
        }
    }

    /* 0..100. */
    private int aggressiveness = DEFAULT_AGGRESSIVENESS;

    private final HashMap<String, List<LocationPoint>> historyCache =
        new HashMap<String, List<LocationPoint>>();

    /* Points removed by normalization, kept so they can be drawn/exported. */
    private final HashMap<String, List<Decision>> decisionCache =
        new HashMap<String, List<Decision>>();

    private final HashSet<String> duplicateKeys = new HashSet<String>();
    private final HashSet<String> rejectedKeys = new HashSet<String>();

    // ------------------------------------------------------------------
    // Settings
    // ------------------------------------------------------------------

    public int getAggressiveness() {
        return aggressiveness;
    }

    /** Clamps to 0..100 and drops cached results, which are now stale. */
    public void setAggressiveness(int value) {

        int clamped = value;

        if (clamped < 0) clamped = 0;
        if (clamped > 100) clamped = 100;

        if (clamped == aggressiveness) return;

        aggressiveness = clamped;

        clear();
    }

    public String levelName() {
        return levelName(aggressiveness);
    }

    public static String levelName(int aggressiveness) {

        if (aggressiveness <= 20) return "Very Light";
        if (aggressiveness <= 40) return "Light";
        if (aggressiveness <= 60) return "Normal";
        if (aggressiveness <= 80) return "Strong";

        return "Very Aggressive";
    }

    /**
     * Light normalization allows up to 250 km/h, very aggressive bottoms out
     * at 20 km/h. An ordinary 5 m/s walk (~18 km/h) survives every setting.
     */
    public double maxSpeedKmh() {
        return scale(MIN_MAX_SPEED_KMH, MAX_MAX_SPEED_KMH);
    }

    public double maxSpeedMps() {
        return maxSpeedKmh() / 3.6;
    }

    public double duplicateDistance() {
        return scale(
            MIN_DUPLICATE_DISTANCE_METERS,
            MAX_DUPLICATE_DISTANCE_METERS);
    }

    public double sameTimeDistance() {
        return scale(
            MIN_SAME_TIME_DISTANCE_METERS,
            MAX_SAME_TIME_DISTANCE_METERS);
    }

    public double accuracyMultiplier() {
        return scale(MIN_ACCURACY_MULTIPLIER, MAX_ACCURACY_MULTIPLIER);
    }

    /** Maps aggressiveness 0..100 onto max..min of a threshold range. */
    private double scale(double minimum, double maximum) {

        double fraction = aggressiveness / 100.0;

        return maximum - ((maximum - minimum) * fraction);
    }

    // ------------------------------------------------------------------
    // Cached results
    // ------------------------------------------------------------------

    public void clear() {
        historyCache.clear();
        decisionCache.clear();
        duplicateKeys.clear();
        rejectedKeys.clear();
    }

    /**
     * Normalizes every distinct device in the list up front, so the map and
     * the export summary can read counts without re-running the engine.
     */
    public void prepare(TrackerDatabase db, List<DeviceRecord> rows) {

        if (db == null || rows == null) return;

        HashSet<String> seen = new HashSet<String>();

        for (int i = 0; i < rows.size(); i++) {

            DeviceRecord record = rows.get(i);

            if (record == null) continue;

            if (record.address == null || record.address.trim().length() == 0) {
                continue;
            }

            if (seen.contains(record.address)) continue;

            seen.add(record.address);

            historyOf(db, record.address);
        }
    }

    /**
     * Normalized history for one device, running the engine (and caching the
     * result) the first time it is asked for.
     */
    public List<LocationPoint> historyOf(TrackerDatabase db, String address) {

        List<LocationPoint> cached = historyCache.get(address);

        if (cached != null) return cached;

        List<LocationPoint> normalized =
            normalize(address, db.history(address, 0L));

        historyCache.put(address, normalized);

        return normalized;
    }

    /** Removed points for one device; never null. */
    public List<Decision> decisionsOf(String address) {

        List<Decision> result = decisionCache.get(address);

        return result == null
            ? new ArrayList<Decision>()
            : result;
    }

    public int duplicateCount() {
        return duplicateKeys.size();
    }

    public int rejectedCount() {
        return rejectedKeys.size();
    }

    /** Points kept across every device normalized so far. */
    public int retainedCount() {

        int retained = 0;

        for (List<LocationPoint> list : historyCache.values()) {
            if (list != null) retained += list.size();
        }

        return retained;
    }

    /**
     * Points examined so far: the decision cache holds only removals and the
     * history cache only survivors, so the raw total is the two combined.
     */
    public int examinedCount() {

        int removed = 0;

        for (List<Decision> list : decisionCache.values()) {
            if (list != null) removed += list.size();
        }

        return removed + retainedCount();
    }

    // ------------------------------------------------------------------
    // Engine
    // ------------------------------------------------------------------

    public List<LocationPoint> normalize(
            String deviceAddress,
            List<LocationPoint> input) {

        ArrayList<LocationPoint> rows = new ArrayList<LocationPoint>();
        ArrayList<Decision> decisions = new ArrayList<Decision>();

        if (input == null) {

            decisionCache.put(deviceAddress, decisions);

            return rows;
        }

        for (int i = 0; i < input.size(); i++) {

            LocationPoint point = input.get(i);

            if (point != null) rows.add(point);
        }

        Collections.sort(rows, new Comparator<LocationPoint>() {

            @Override
            public int compare(LocationPoint a, LocationPoint b) {

                long ta = pointTime(a);
                long tb = pointTime(b);

                if (ta < tb) return -1;
                if (ta > tb) return 1;

                return 0;
            }
        });

        /*
         * Pass 1: drop points identical to one already seen.
         */

        ArrayList<LocationPoint> unique = new ArrayList<LocationPoint>();

        HashSet<String> exact = new HashSet<String>();

        for (int i = 0; i < rows.size(); i++) {

            LocationPoint point = rows.get(i);

            String key = buildKey(deviceAddress, point);

            if (exact.contains(key)) {

                duplicateKeys.add(key);

                decisions.add(new Decision(
                    point,
                    unique.size() > 0
                        ? unique.get(unique.size() - 1)
                        : null,
                    "Exact duplicate",
                    0,
                    0,
                    0,
                    duplicateDistance()));

                continue;
            }

            exact.add(key);
            unique.add(point);
        }

        /*
         * Pass 2: walk the timeline and judge each point against the last
         * one that was trusted.
         */

        ArrayList<LocationPoint> normalized = new ArrayList<LocationPoint>();

        LocationPoint previousAccepted = null;

        for (int i = 0; i < unique.size(); i++) {

            LocationPoint point = unique.get(i);

            long timestamp = pointTime(point);

            double lat = point.latitude;
            double lon = point.longitude;

            double accuracy = pointDouble(point, "accuracy", -1);

            /* Nothing to compare against - keep it and move on. */

            if (timestamp <= 0L) {
                normalized.add(point);
                continue;
            }

            if (!isValidCoordinate(lat, lon)) {
                normalized.add(point);
                continue;
            }

            if (previousAccepted == null) {
                normalized.add(point);
                previousAccepted = point;
                continue;
            }

            long previousTimestamp = pointTime(previousAccepted);

            double previousLat = previousAccepted.latitude;
            double previousLon = previousAccepted.longitude;

            if (previousTimestamp <= 0L
                    || !isValidCoordinate(previousLat, previousLon)) {

                normalized.add(point);
                previousAccepted = point;
                continue;
            }

            double elapsed = (timestamp - previousTimestamp) / 1000.0;

            double distance =
                haversineMeters(previousLat, previousLon, lat, lon);

            if (elapsed < 0) {
                normalized.add(point);
                previousAccepted = point;
                continue;
            }

            /*
             * ========================================================
             * SAME TIMESTAMP
             * ========================================================
             */

            if (elapsed == 0) {

                double previousAccuracy =
                    pointDouble(previousAccepted, "accuracy", 0);

                double allowedDistance = Math.max(
                    sameTimeDistance(),
                    (Math.max(accuracy, 0) + Math.max(previousAccuracy, 0))
                        * accuracyMultiplier());

                String key = buildKey(deviceAddress, point);

                if (distance <= allowedDistance) {

                    duplicateKeys.add(key);

                    decisions.add(new Decision(
                        point,
                        previousAccepted,
                        "Same timestamp / duplicate GPS fix",
                        distance,
                        elapsed,
                        0,
                        allowedDistance));

                    continue;
                }

                rejectedKeys.add(key);

                decisions.add(new Decision(
                    point,
                    previousAccepted,
                    "Conflicting GPS point at same timestamp",
                    distance,
                    elapsed,
                    0,
                    allowedDistance));

                continue;
            }

            /*
             * ========================================================
             * NORMAL MOVEMENT
             * ========================================================
             */

            double previousAccuracy =
                pointDouble(previousAccepted, "accuracy", 0);

            double combinedAccuracy = Math.max(previousAccuracy, accuracy);

            double movementTolerance = Math.max(
                duplicateDistance(),
                combinedAccuracy * accuracyMultiplier());

            if (distance <= movementTolerance) {
                normalized.add(point);
                previousAccepted = point;
                continue;
            }

            /*
             * ========================================================
             * PHYSICAL SPEED TEST
             * ========================================================
             */

            double speedKmh = (distance / elapsed) * 3.6;

            double physicallyPossibleDistance = maxSpeedMps() * elapsed;

            if (distance > physicallyPossibleDistance) {

                rejectedKeys.add(buildKey(deviceAddress, point));

                decisions.add(new Decision(
                    point,
                    previousAccepted,
                    "Impossible GPS movement",
                    distance,
                    elapsed,
                    speedKmh,
                    maxSpeedKmh()));

                /*
                 * IMPORTANT:
                 *
                 * Do not advance previousAccepted when a point is
                 * rejected. The next point is still compared against
                 * the last trustworthy point.
                 */
                continue;
            }

            normalized.add(point);
            previousAccepted = point;
        }

        decisionCache.put(deviceAddress, decisions);

        return normalized;
    }

    public static String buildKey(String deviceAddress, LocationPoint point) {

        if (point == null) return "";

        return String.valueOf(deviceAddress)
            + "|" + pointTime(point)
            + "|" + Double.toString(point.latitude)
            + "|" + Double.toString(point.longitude);
    }

    public static boolean isValidCoordinate(double latitude, double longitude) {

        return !Double.isNaN(latitude)
            && !Double.isNaN(longitude)
            && !Double.isInfinite(latitude)
            && !Double.isInfinite(longitude)
            && latitude >= -90.0
            && latitude <= 90.0
            && longitude >= -180.0
            && longitude <= 180.0;
    }

    public static double haversineMeters(
            double lat1,
            double lon1,
            double lat2,
            double lon2) {

        double rLat1 = Math.toRadians(lat1);
        double rLon1 = Math.toRadians(lon1);
        double rLat2 = Math.toRadians(lat2);
        double rLon2 = Math.toRadians(lon2);

        double dLat = rLat2 - rLat1;
        double dLon = rLon2 - rLon1;

        double a =
            Math.sin(dLat / 2.0) * Math.sin(dLat / 2.0)
                + Math.cos(rLat1)
                    * Math.cos(rLat2)
                    * Math.sin(dLon / 2.0)
                    * Math.sin(dLon / 2.0);

        double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(1.0 - a));

        return EARTH_RADIUS_METERS * c;
    }

    // ------------------------------------------------------------------
    // LocationPoint reflection
    //
    // The point class has changed shape more than once (timestamp vs time
    // vs recordedAt, and rssi/battery only exist on some builds), so these
    // read whatever field is actually there instead of assuming.
    // ------------------------------------------------------------------

    private static final String[] TIME_FIELDS = {
        "timestamp",
        "time",
        "recordedAt",
        "createdAt",
        "updatedAt",
        "timeMillis",
        "timestampMillis",
        "recordedTime"
    };

    public static long pointTime(LocationPoint point) {

        if (point == null) return 0L;

        for (int i = 0; i < TIME_FIELDS.length; i++) {

            Object value = readField(point, TIME_FIELDS[i]);

            if (value == null) continue;

            if (value instanceof Number) {
                return millis(((Number) value).longValue());
            }

            if (value instanceof Date) {
                return ((Date) value).getTime();
            }

            if (value instanceof String) {

                try {
                    return millis(Long.parseLong((String) value));

                } catch (Exception ignored) {
                }
            }
        }

        return 0L;
    }

    /** Promotes a seconds-since-epoch value to milliseconds. */
    private static long millis(long value) {

        return value > 0 && value < 100000000000L
            ? value * 1000L
            : value;
    }

    public static double pointDouble(
            LocationPoint point,
            String name,
            double fallback) {

        Object value = readField(point, name);

        if (value instanceof Number) {
            return ((Number) value).doubleValue();
        }

        if (value instanceof String) {

            try {
                return Double.parseDouble((String) value);

            } catch (Exception ignored) {
            }
        }

        return fallback;
    }

    private static Object readField(LocationPoint point, String name) {

        if (point == null) return null;

        Class<?> cls = point.getClass();

        while (cls != null) {

            try {

                Field field = cls.getDeclaredField(name);

                field.setAccessible(true);

                return field.get(point);

            } catch (Exception ignored) {

                cls = cls.getSuperclass();
            }
        }

        return null;
    }
}
