package com.wytehat.btlogger;

import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Looper;

import org.json.JSONObject;

import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;

/**
 * Ships recorded sightings to the ingestion server as a CSV upload.
 *
 * The server deduplicates on (mac, timestamp, latitude, longitude), so
 * re-uploading the whole database is safe and idempotent - anything it has
 * already seen is counted and skipped. That is why this uploads everything
 * rather than trying to track what was sent last time: the client staying
 * dumb is what makes a failed or partial upload harmless to retry.
 *
 * Network work must not run on the main thread; use {@link #uploadAsync}, or
 * call {@link #upload} from a background thread yourself.
 */
public final class ServerUploader {

    public static final String PREFS = "server_settings";

    public static final String KEY_URL = "server_url";
    public static final String KEY_API_KEY = "server_api_key";
    public static final String KEY_LAST_UPLOAD = "last_upload_at";

    public static final String DEFAULT_URL =
        "https://bt.justbug.me/api/v1/bt/upload";

    public static final String DEFAULT_API_KEY =
        "android-app-key-12345-change-in-production";

    /** Columns the server's importer recognises without any aliasing. */
    private static final String HEADER =
        "mac_address,device_name,device_class,rssi,latitude,longitude,timestamp";

    private static final int CONNECT_TIMEOUT_MS = 20000;
    private static final int READ_TIMEOUT_MS = 60000;

    /** Outcome of one upload, mirroring the server's response metrics. */
    public static class Result {

        public boolean ok;
        public int httpStatus;

        public int totalReceived;
        public int newPointsAdded;
        public int duplicatesSkipped;
        public int invalidRows;

        public int rowsSent;
        public String message = "";

        /** Identifier the server read from X-Device-ID, echoed back. */
        public String deviceId = "";

        /** One line suitable for a toast or a status label. */
        public String summary() {

            if (!ok) return "Upload failed: " + message;

            return "Uploaded " + rowsSent + " row(s) — "
                + newPointsAdded + " new, "
                + duplicatesSkipped + " already known"
                + (invalidRows > 0 ? ", " + invalidRows + " rejected" : "")
                + ".";
        }
    }

    public interface Callback {
        void onFinished(Result result);
    }

    private ServerUploader() {
    }

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

    public static String serverUrl(Context context) {
        return prefs(context).getString(KEY_URL, DEFAULT_URL);
    }

    public static String apiKey(Context context) {
        return prefs(context).getString(KEY_API_KEY, DEFAULT_API_KEY);
    }

    public static long lastUploadAt(Context context) {
        return prefs(context).getLong(KEY_LAST_UPLOAD, 0L);
    }

    public static void saveSettings(Context context, String url, String key) {

        prefs(context).edit()
            .putString(KEY_URL, url == null || url.trim().length() == 0
                ? DEFAULT_URL : url.trim())
            .putString(KEY_API_KEY, key == null || key.trim().length() == 0
                ? DEFAULT_API_KEY : key.trim())
            .apply();
    }

    private static SharedPreferences prefs(Context context) {
        return context.getSharedPreferences(PREFS, 0);
    }

    // ------------------------------------------------------------------
    // CSV
    // ------------------------------------------------------------------

    /**
     * Every stored sighting as CSV: one row per device's current fix, plus one
     * row per recorded history point, which is what carries the trail.
     */
    public static String buildCsv(TrackerDatabase database) {

        StringBuilder csv = new StringBuilder(HEADER).append('\n');

        SimpleDateFormat stamp =
            new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);

        // The server reads these as UTC; sending local time would shift every
        // point by the phone's offset.
        stamp.setTimeZone(TimeZone.getTimeZone("UTC"));

        List<DeviceRecord> devices = database.all();

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

            DeviceRecord record = devices.get(i);

            if (record == null || record.address == null) continue;

            String name = record.displayName();
            String category = record.category;

            if (record.hasLocation) {

                appendRow(csv, stamp, record.address, name, category,
                    record.rssi != 0 ? Integer.toString(record.rssi) : "",
                    record.latitude, record.longitude,
                    record.updatedAt > 0
                        ? record.updatedAt
                        : System.currentTimeMillis());
            }

            List<LocationPoint> history = database.history(record.address, 0L);

            for (int p = 0; p < history.size(); p++) {

                LocationPoint point = history.get(p);

                if (point == null) continue;

                long time = point.timestamp > 0
                    ? point.timestamp
                    : record.updatedAt;

                if (time <= 0) continue;

                // History rows carry no RSSI of their own - the column stays
                // empty rather than repeating the device's latest reading,
                // which was measured somewhere else entirely.
                appendRow(csv, stamp, record.address, name, category, "",
                    point.latitude, point.longitude, time);
            }
        }

        return csv.toString();
    }

    private static void appendRow(StringBuilder csv, SimpleDateFormat stamp,
                                  String mac, String name, String category,
                                  String rssi, double latitude,
                                  double longitude, long time) {

        csv.append(field(mac)).append(',')
            .append(field(name)).append(',')
            .append(field(category)).append(',')
            .append(rssi).append(',')
            .append(latitude).append(',')
            .append(longitude).append(',')
            .append(stamp.format(new Date(time)))
            .append('\n');
    }

    /** Quotes a CSV field only when it needs it. */
    private static String field(String value) {

        if (value == null) return "";

        if (value.indexOf(',') < 0
                && value.indexOf('"') < 0
                && value.indexOf('\n') < 0
                && value.indexOf('\r') < 0) {

            return value;
        }

        return '"' + value.replace("\"", "\"\"")
            .replace("\r", " ")
            .replace("\n", " ") + '"';
    }

    /** Rows in a CSV body, excluding the header. */
    private static int countRows(String csv) {

        int rows = 0;

        for (int i = 0; i < csv.length(); i++) {
            if (csv.charAt(i) == '\n') rows++;
        }

        return Math.max(0, rows - 1);
    }

    // ------------------------------------------------------------------
    // Upload
    // ------------------------------------------------------------------

    /** Builds the CSV and POSTs it. Blocking: never call this on the UI thread. */
    public static Result upload(Context context) {

        Result result = new Result();

        TrackerDatabase database = null;

        String csv;

        try {

            database = new TrackerDatabase(context);
            csv = buildCsv(database);

        } catch (Exception error) {

            result.message = "Could not read the local database: "
                + error.getMessage();
            return result;

        } finally {

            if (database != null) {
                try { database.close(); } catch (Exception ignored) { }
            }
        }

        result.rowsSent = countRows(csv);

        if (result.rowsSent == 0) {
            result.message = "Nothing recorded yet — nothing to upload.";
            return result;
        }

        return post(context, csv, result);
    }

    private static Result post(Context context, String csv, Result result) {

        String boundary = "----BTLogger" + System.currentTimeMillis();

        HttpURLConnection connection = null;

        try {

            URL endpoint = new URL(serverUrl(context));

            connection = (HttpURLConnection) endpoint.openConnection();

            connection.setRequestMethod("POST");
            connection.setDoOutput(true);
            connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
            connection.setReadTimeout(READ_TIMEOUT_MS);

            connection.setRequestProperty("X-API-Key", apiKey(context));

            // Identifies which phone recorded these sightings. The server
            // stores it against every point and refuses the upload with a 400
            // if it is missing or malformed, so it goes on every request.
            connection.setRequestProperty(
                "X-Device-ID", DeviceIdentity.get(context));

            connection.setRequestProperty("Accept", "application/json");
            connection.setRequestProperty("Content-Type",
                "multipart/form-data; boundary=" + boundary);

            // Streamed rather than buffered so a large export does not have to
            // fit in memory twice on a phone.
            connection.setChunkedStreamingMode(16 * 1024);

            DataOutputStream body =
                new DataOutputStream(connection.getOutputStream());

            body.writeBytes("--" + boundary + "\r\n");
            body.writeBytes("Content-Disposition: form-data; name=\"file\"; "
                + "filename=\"btlogger.csv\"\r\n");
            body.writeBytes("Content-Type: text/csv\r\n\r\n");

            body.write(csv.getBytes("UTF-8"));

            body.writeBytes("\r\n--" + boundary + "--\r\n");

            body.flush();
            body.close();

            result.httpStatus = connection.getResponseCode();

            String response = readAll(result.httpStatus >= 400
                ? connection.getErrorStream()
                : connection.getInputStream());

            if (result.httpStatus == 401) {
                result.message = "Server rejected the API key.";
                return result;
            }

            if (result.httpStatus >= 400) {

                // The server explains itself in an "error" field; showing that
                // beats showing the raw JSON envelope around it.
                String detail = errorMessage(response);

                result.message = "HTTP " + result.httpStatus
                    + (detail.length() > 0 ? ": " + detail : "");

                return result;
            }

            parseMetrics(response, result);

            result.ok = true;

            prefs(context).edit()
                .putLong(KEY_LAST_UPLOAD, System.currentTimeMillis())
                .apply();

            return result;

        } catch (Exception error) {

            result.message = error.getClass().getSimpleName()
                + (error.getMessage() != null ? ": " + error.getMessage() : "");

            return result;

        } finally {

            if (connection != null) connection.disconnect();
        }
    }

    private static void parseMetrics(String response, Result result) {

        try {

            JSONObject json = new JSONObject(response);

            result.totalReceived = json.optInt("total_received", 0);
            result.newPointsAdded = json.optInt("new_points_added", 0);
            result.duplicatesSkipped = json.optInt("duplicates_skipped", 0);
            result.invalidRows = json.optInt("invalid_rows", 0);
            result.deviceId = json.optString("device_id", "");

        } catch (Exception ignored) {
            // A 2xx with an unreadable body still means the server took it.
        }
    }

    /**
     * The server's "error" field, or the trimmed body when it is not JSON.
     */
    private static String errorMessage(String response) {

        if (response == null || response.length() == 0) return "";

        try {
            String error = new JSONObject(response).optString("error", "");
            if (error.length() > 0) return error;
        } catch (Exception ignored) {
            // Not JSON - a proxy error page, most likely.
        }

        return trim(response);
    }

    private static String readAll(InputStream stream) {

        if (stream == null) return "";

        StringBuilder text = new StringBuilder();

        BufferedReader reader = null;

        try {

            reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"));

            String line;

            while ((line = reader.readLine()) != null) {
                text.append(line);
            }

        } catch (Exception ignored) {

        } finally {

            if (reader != null) {
                try { reader.close(); } catch (Exception ignored) { }
            }
        }

        return text.toString();
    }

    private static String trim(String value) {
        return value.length() > 200 ? value.substring(0, 200) + "…" : value;
    }

    /**
     * Runs {@link #upload} on a background thread and delivers the result on
     * the main thread.
     */
    public static void uploadAsync(final Context context,
                                   final Callback callback) {

        final Context application = context.getApplicationContext();
        final Handler main = new Handler(Looper.getMainLooper());

        new Thread(new Runnable() {
            @Override
            public void run() {

                final Result result = upload(application);

                if (callback == null) return;

                main.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onFinished(result);
                    }
                });
            }
        }, "bt-upload").start();
    }
}
