package outbackcdx;


import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;

import org.rocksdb.RocksDB;
import org.rocksdb.RocksDBException;

import outbackcdx.NanoHTTPD.IStreamer;
import outbackcdx.NanoHTTPD.Response;
import outbackcdx.NanoHTTPD.Response.Status;
import outbackcdx.auth.Permission;

import javax.xml.stream.XMLStreamException;
import java.io.*;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

import static java.lang.System.out;
import static java.nio.charset.StandardCharsets.UTF_8;
import static outbackcdx.Json.GSON;
import static outbackcdx.NanoHTTPD.Method.*;
import static outbackcdx.NanoHTTPD.Response.Status.*;
import static outbackcdx.Web.*;

import org.rocksdb.TransactionLogIterator;
import org.rocksdb.TransactionLogIterator.BatchResult;

class Webapp implements Web.Handler {
    private final boolean verbose;
    private final DataStore dataStore;
    private final Web.Router router;
    private final Map<String,Object> dashboardConfig;
    private final ArrayList<FilterPlugin> filterPlugins;
    private final UrlCanonicalizer canonicalizer;
    private final Map<String, ComputedField> computedFields;
    private final WbCdxApi wbCdxApi;

    private static ServiceLoader<FilterPlugin> fpLoader = ServiceLoader.load(FilterPlugin.class);

    private Response configJson(Web.Request req) {
        return jsonResponse(dashboardConfig);
    }

    private Response listAccessPolicies(Web.Request req) throws IOException, Web.ResponseException {
        return jsonResponse(getIndex(req).accessControl.listPolicies());
    }

    private Response deleteAccessRule(Web.Request req) throws IOException, Web.ResponseException, RocksDBException {
        long ruleId = Long.parseLong(req.param("ruleId"));
        boolean found = getIndex(req).accessControl.deleteRule(ruleId);
        return found ? ok() : notFound();
    }

    Webapp(DataStore dataStore, boolean verbose, Map<String, Object> dashboardConfig, UrlCanonicalizer canonicalizer, Map<String, ComputedField> computedFields) {
        this.dataStore = dataStore;
        this.verbose = verbose;
        this.dashboardConfig = dashboardConfig;
        if (canonicalizer == null) {
            canonicalizer = new UrlCanonicalizer();
        }
        this.canonicalizer = canonicalizer;
        this.computedFields = computedFields;

        this.filterPlugins = new ArrayList<FilterPlugin>();
        if (FeatureFlags.filterPlugins()) {
            System.out.println("Loading plugins");
            for (FilterPlugin f : Webapp.fpLoader) {
                System.out.println("Loaded plugin");
                this.filterPlugins.add(f);
            }
        }

        wbCdxApi = new WbCdxApi(filterPlugins, computedFields);

        router = new Router();
        router.on(GET, "/", interpolated("dashboard.html"));
        router.on(GET, "/api", serve("api.html"));
        router.on(GET, "/api.js", serve("api.js"));
        router.on(GET, "/add.svg", serve("add.svg"));
        router.on(GET, "/database.svg", serve("database.svg"));
        router.on(GET, "/outback.svg", serve("outback.svg"));
        router.on(GET, "/favicon.ico", serve("outback.svg"));
        router.on(GET, "/swagger.json", serve("swagger.json"));
        router.on(GET, "/lib/vue-router/2.0.0/vue-router.js", serve("lib/vue-router/2.0.0/vue-router.js"));
        router.on(GET, "/lib/vue/" + version("org.webjars.npm", "vue") + "/vue.js", serve("/META-INF/resources/webjars/vue/" + version("org.webjars.npm", "vue") + "/dist/vue.js"));
        router.on(GET, "/lib/lodash/" + version("org.webjars", "lodash") + "/lodash.min.js", serve("/META-INF/resources/webjars/lodash/" + version("org.webjars", "lodash") + "/lodash.min.js"));
        router.on(GET, "/lib/moment/" + version("org.webjars.npm", "moment") + "/moment.min.js", serve("/META-INF/resources/webjars/moment/" + version("org.webjars.npm", "moment") + "/min/moment.min.js"));
        router.on(GET, "/lib/pikaday/" + version("org.webjars.npm", "pikaday") + "/pikaday.js", serve("/META-INF/resources/webjars/pikaday/" + version("org.webjars.npm", "pikaday") + "/pikaday.js"));
        router.on(GET, "/lib/pikaday/" + version("org.webjars.npm", "pikaday") + "/pikaday.css", serve("/META-INF/resources/webjars/pikaday/" + version("org.webjars.npm", "pikaday") + "/css/pikaday.css"));
        router.on(GET, "/lib/redoc/" + version("org.webjars.bower", "redoc") + "/redoc.min.js", serve("/META-INF/resources/webjars/redoc/" + version("org.webjars.bower", "redoc") + "/dist/redoc.min.js"));
        router.on(GET, "/api/collections", request1 -> listCollections(request1));
        router.on(GET, "/config.json", req1 -> configJson(req1));
        router.on(GET, "/<collection>", request -> query(request));
        router.on(POST, "/<collection>", request -> post(request), Permission.INDEX_EDIT);
        router.on(POST, "/<collection>/delete", request -> delete(request), Permission.INDEX_EDIT);
        router.on(GET, "/<collection>/stats", req2 -> stats(req2));
        router.on(GET, "/<collection>/captures", request -> captures(request));
        router.on(GET, "/<collection>/aliases", request -> aliases(request));
        router.on(GET, "/<collection>/changes", request -> changeFeed(request));
        router.on(GET, "/<collection>/sequence", request -> sequence(request));
        router.on(POST, "/<collection>/truncate_replication", request -> flushWal(request));

        if (FeatureFlags.experimentalAccessControl()) {
            router.on(GET, "/<collection>/ap/<accesspoint>", request -> query(request));
            router.on(GET, "/<collection>/ap/<accesspoint>/check", request1 -> checkAccess(request1));
            router.on(POST, "/<collection>/ap/<accesspoint>/check", request -> checkAccessBulk(request));
            router.on(GET, "/<collection>/access/rules", request -> listAccessRules(request));
            router.on(POST, "/<collection>/access/rules", request -> postAccessRules(request), Permission.RULES_EDIT);
            router.on(GET, "/<collection>/access/rules/new", request1 -> getNewAccessRule(request1), Permission.RULES_EDIT);
            router.on(GET, "/<collection>/access/rules/<ruleId>", req -> getAccessRule(req));
            router.on(DELETE, "/<collection>/access/rules/<ruleId>", req1 -> deleteAccessRule(req1), Permission.RULES_EDIT);
            router.on(GET, "/<collection>/access/policies", req1 -> listAccessPolicies(req1));
            router.on(POST, "/<collection>/access/policies", request -> postAccessPolicy(request), Permission.POLICIES_EDIT);
            router.on(GET, "/<collection>/access/policies/<policyId>", req -> getAccessPolicy(req));
        }
    }

    Response flushWal(Web.Request request) throws Web.ResponseException, IOException, RocksDBException{
        Index index = getIndex(request);
        index.flushWal();
        Map<String,Boolean> map = new HashMap<>();
        map.put("success", true);
        return jsonResponse(map);
    }

    Response listCollections(Web.Request request) {
        return jsonResponse(dataStore.listCollections());
    }

    Response stats(Web.Request req) throws IOException, Web.ResponseException {
        Index index = getIndex(req);
        Map<String,Object> map = new HashMap<>();
        map.put("estimatedRecordCount", index.estimatedRecordCount());

        for (String property : req.param("property", "").split(",")) {
            try {
                map.put(property, index.db.getProperty(property));
            } catch (RocksDBException e) {
                map.put(property, "ERROR: " + e);
            }
        }

        Response response = new Response(Response.Status.OK, "application/json",
                GSON.toJson(map));
        response.addHeader("Access-Control-Allow-Origin", "*");
        return response;
    }

    Response captures(Web.Request request) throws IOException, Web.ResponseException {
        Index index = getIndex(request);
        String key = request.param("key", "");
        long limit = Long.parseLong(request.param("limit", "1000"));
        List<Capture> results = StreamSupport.stream(index.capturesAfter(key).spliterator(), false)
                .limit(limit)
                .collect(Collectors.<Capture>toList());
        return jsonResponse(results);
    }

    Response aliases(Web.Request request) throws IOException, Web.ResponseException {
        Index index = getIndex(request);
        String key = request.param("key", "");
        long limit = Long.parseLong(request.param("limit", "1000"));
        List<Alias> results = StreamSupport.stream(index.listAliases(key).spliterator(), false)
                .limit(limit)
                .collect(Collectors.<Alias>toList());
        return jsonResponse(results);
    }

    Response delete(Web.Request request) throws IOException {
        if(FeatureFlags.isSecondary() && !FeatureFlags.acceptsWrites()){
            return new Response(FORBIDDEN, "text/plain", "This node is running in secondary mode to an upstream primary, and will not accept writes.");
        }
        String collection = request.param("collection");
        boolean recanonicalize = !"0".equals(request.param("recanonicalize", "1"));
        final Index index = dataStore.getIndex(collection);
        BufferedReader in = new BufferedReader(new InputStreamReader(request.inputStream()));
        long deleted = 0;

        try (Index.Batch batch = index.beginUpdate()) {
            while (true) {
                String line = in.readLine();
                if (verbose) {
                    out.println("DELETE " + line);
                }
                if (line == null) break;
                if (line.startsWith(" CDX")) continue;

                try {
                    if (line.startsWith("@alias ")) {
                        throw new UnsupportedOperationException("Deleting of aliases is not yet implemented");
                    }

                    if (recanonicalize) {
                        batch.deleteCapture(Capture.fromCdxLine(line, canonicalizer));
                    } else {
                        String[] fields = line.split(" ", 3);
                        Capture capture = new Capture();
                        capture.urlkey = fields[0];
                        capture.timestamp = Long.valueOf(fields[1]);
                        batch.deleteCapture(capture);
                    }
                    deleted++;
                } catch (Exception e) {
                    return new Response(BAD_REQUEST, "text/plain", "At line: " + line + "\n" + formatStackTrace(e));
                }
            }

            batch.commit();
        }
        return new Response(OK, "text/plain", "Deleted " + deleted + " records\n");
    }

    Response post(Web.Request request) throws IOException {
        if(FeatureFlags.isSecondary() && !FeatureFlags.acceptsWrites()){
            return new Response(FORBIDDEN, "text/plain", "This node is running in secondary mode to an upstream primary, and will not accept writes.");
        }
        String collection = request.param("collection");
        boolean skipBadLines = "skip".equals(request.param("badLines", "error"));
        final Index index = dataStore.getIndex(collection, true);
        BufferedReader in = new BufferedReader(new InputStreamReader(request.inputStream()));
        long added = 0;

        try (Index.Batch batch = index.beginUpdate()) {
            while (true) {
                String line = in.readLine();
                if (verbose) {
                    out.println(line);
                }
                if (line == null) break;
                if (line.startsWith(" CDX")) continue;

                try {
                    if (line.startsWith("@alias ")) {
                        String[] fields = line.split(" ");
                        String aliasSurt = canonicalizer.surtCanonicalize(fields[1]);
                        String targetSurt = canonicalizer.surtCanonicalize(fields[2]);
                        batch.putAlias(aliasSurt, targetSurt);
                        added++;
                    } else {
                        try  {
                            batch.putCapture(Capture.fromCdxLine(line, canonicalizer));
                            added++;
                        } catch (Exception e) {
                            if (skipBadLines) {
                                System.err.println("skipping bad cdx line: " + line);
                                e.printStackTrace();
                            } else {
                                throw e;
                            }
                        }
                    }
                } catch (Exception e) {
                    return new Response(BAD_REQUEST, "text/plain", "At line: " + line + "\n" + formatStackTrace(e));
                }
            }

            batch.commit();
        }
        System.out.println(new Date() + " " + request.method() + " " + request.url() + " Added " + added
                + " records. latestSequenceNumber=" + index.getLatestSequenceNumber());

        return new Response(OK, "text/plain", "Added " + added + " records\n");
    }

    private String formatStackTrace(Exception e) {
        StringWriter stacktrace = new StringWriter();
        e.printStackTrace(new PrintWriter(stacktrace));
        return stacktrace.toString();
    }

    Response sequence(Web.Request request) throws IOException, ResponseException {
        final Index index = getIndex(request);
        String output = String.valueOf(index.db.getLatestSequenceNumber());
        return new Response(OK, "text/plain", output);
    }

    static class ChangeFeedJsonStream implements IStreamer {
        TransactionLogIterator logReader;
        long batchSize;

        ChangeFeedJsonStream(TransactionLogIterator logReader, long batchSize) {
            this.logReader = logReader;
            this.batchSize = batchSize;
        }

        @Override
        public void stream(OutputStream outputStream) throws IOException {
            // build and stream json as bytes to avoid overhead of utf-16 String
            try {
                BufferedOutputStream output = new BufferedOutputStream(outputStream);
                output.write("[\n".getBytes(UTF_8));

                long size = 0l;
                long initialSeqNo = -1;
                while (true) {
                    BatchResult batch = logReader.getBatch();

                    output.write("{\"sequenceNumber\": \"".getBytes(UTF_8));
                    output.write(Long.toString(batch.sequenceNumber()).getBytes(UTF_8));
                    output.write("\", \"writeBatch\": \"".getBytes(UTF_8));
                    byte[] b64Batch;
                    try {
                        b64Batch = Base64.getEncoder().encode(batch.writeBatch().data());
                    } catch (RocksDBException e) {
                        throw new IOException(e);
                    }
                    output.write(b64Batch);
                    output.write("\"}".getBytes(UTF_8));

                    logReader.next();
                    size += b64Batch.length;

                    if (initialSeqNo < 0) {
                        initialSeqNo = batch.sequenceNumber();
                    }

                    if (logReader.isValid() && (size < batchSize || batch.sequenceNumber() == initialSeqNo)) {
                        output.write(",\n".getBytes(UTF_8));
                    } else {
                        break;
                    }
                }
                output.write("\n]\n".getBytes(UTF_8));
                output.flush();
            } finally {
                logReader.close();
            }
        }
    }

    Response changeFeed(Web.Request request) throws Web.ResponseException, IOException {
        String collection = request.param("collection");
        long since = Long.parseLong(request.param("since", "0"));
        long size = 10*1024*1024;
        if (request.param("size") != null) {
            size = Long.parseLong(request.param("size"));
        }

        final Index index = getIndex(request);

        if (verbose) {
            out.println(String.format("%s Received request %s. Retrieving deltas for collection <%s> since sequenceNumber %s", new Date(), request, collection, since));
        }

        try {
            /* This method must not close logReader, or you will get a segfault.
             * The response payload stream class ChangeFeedJsonStream closes it
             * when it's finished with it. */
            TransactionLogIterator logReader = index.getUpdatesSince(since);

            ChangeFeedJsonStream streamer = new ChangeFeedJsonStream(logReader, size);
            Response response = new Response(OK, "application/json", streamer);
            response.addHeader("Access-Control-Allow-Origin", "*");
            return response;
        } catch (RocksDBException e) {
            System.err.println(new Date() + " " + request.method() + " " + request.url() + " - " + e);
            if (!"Requested sequence not yet written in the db".equals(e.getMessage())) {
                e.printStackTrace();
            }
            throw new Web.ResponseException(
                    new Response(Status.INTERNAL_ERROR, "text/plain", e.toString() + "\n"));
        }
    }

    Response query(Web.Request request) throws IOException, Web.ResponseException {
        Index index = getIndex(request);
        Map<String,String> params = request.params();
        if (params.keySet().size() == 1 && params.containsKey("collection")) {
            return collectionDetails(index.db);
        } else if (params.containsKey("q")) {
            return XmlQuery.queryIndex(request, index, this.filterPlugins, canonicalizer);
        } else {
            return wbCdxApi.queryIndex(request, index);
        }
    }

    private Index getIndex(Web.Request request) throws IOException, Web.ResponseException {
        String collection = request.param("collection");
        final Index index = dataStore.getIndex(collection);
        if (index == null) {
            throw new Web.ResponseException(new Response(NOT_FOUND, "text/plain", "Collection " + collection + " does not exist"));
        }
        return index;
    }

    private Response collectionDetails(RocksDB db) {
        String page = "<form>URL: <input name=url type=url><button type=submit>Query</button></form>\n<pre>";
        try {
            page += db.getProperty("rocksdb.stats");
            page += "\nEstimated number of records: " + db.getLongProperty("rocksdb.estimate-num-keys");
        } catch (RocksDBException e) {
            page += e.toString();
            e.printStackTrace();
        }
        return new Response(OK, "text/html", page);
    }

    private <T> T fromJson(Web.Request request, Class<T> clazz) {
        return GSON.fromJson(new InputStreamReader(request.inputStream(), UTF_8), clazz);
    }

    private Response getAccessPolicy(Web.Request req) throws IOException, Web.ResponseException {
        long policyId = Long.parseLong(req.param("policyId"));
        AccessPolicy policy = getIndex(req).accessControl.policy(policyId);
        if (policy == null) {
            return notFound();
        }
        return jsonResponse(policy);
    }

    private Response postAccessPolicy(Web.Request request) throws IOException, Web.ResponseException, RocksDBException {
        AccessPolicy policy = fromJson(request, AccessPolicy.class);
        Long id = getIndex(request).accessControl.put(policy);
        return id == null ? ok() : created(id);
    }

    private Response postAccessRules(Web.Request request) throws IOException, Web.ResponseException, RocksDBException {
        AccessControl accessControl = getIndex(request).accessControl;

        // parse rules
        List<AccessRule> rules;
        boolean single = false;
        if ("application/xml".equals(request.header("content-type"))) {
            try {
                rules = AccessRuleXml.parseRules(request.inputStream());
            } catch (XMLStreamException e) {
                return new Response(BAD_REQUEST, "text/plain", formatStackTrace(e));
            }
        } else { // JSON format
            JsonReader reader = GSON.newJsonReader(new InputStreamReader(request.inputStream(), UTF_8));
            if (reader.peek() == JsonToken.BEGIN_ARRAY) {
                reader.beginArray();
                rules = new ArrayList<>();
                while (reader.hasNext()) {
                    AccessRule rule = GSON.fromJson(reader, AccessRule.class);
                    rules.add(rule);
                }
                reader.endArray();
            } else { // single rule
                rules = Arrays.asList((AccessRule)GSON.fromJson(reader, AccessRule.class));
                single = true;
            }
        }

        // validate rules
        List<AccessRuleError> errors = new ArrayList<>();
        for (AccessRule rule: rules) {
            errors.addAll(rule.validate());
        }

        // return an error response if any failed
        if (!errors.isEmpty()) {
            Response response = jsonResponse(errors);
            response.setStatus(BAD_REQUEST);
            return response;
        }

        // save all the rules
        List<Long> ids = new ArrayList<>();
        for (AccessRule rule : rules) {
            ids.add(accessControl.put(rule, request.username()));
        }

        // return successful response
        if (single) {
            Long id = ids.get(0);
            return id == null ? ok() : created(id);
        } else {
            return jsonResponse(ids);
        }
    }

    private Response ok() {
        return new Response(OK, null, "");
    }

    private Response created(long id) {
        Map<String,String> map = new HashMap<>();
        map.put("id", Long.toString(id));
        return new Response(CREATED, "application/json", GSON.toJson(map));
    }

    private Response getAccessRule(Web.Request req) throws IOException, Web.ResponseException, RocksDBException {
        Index index = getIndex(req);
        Long ruleId = Long.parseLong(req.param("ruleId"));
        AccessRule rule = index.accessControl.rule(ruleId);
        if (rule == null) {
            return notFound();
        }
        return jsonResponse(rule);
    }

    private Response getNewAccessRule(Web.Request request) {
        AccessRule rule = new AccessRule();
        return jsonResponse(rule);
    }

    private Response listAccessRules(Web.Request request) throws IOException, Web.ResponseException {
        Index index = getIndex(request);

        // search filter
        String search = request.param("search");
        List<AccessRule> rules = new ArrayList<>();
        for (AccessRule rule : index.accessControl.list()) {
            if (search == null || rule.contains(search)) {
                rules.add(rule);
            }
        }

        // sort rules
        String sort = request.param("sort", "id");
        if (sort.replaceFirst("^-", "").equals("surt")) {
            Comparator<AccessRule> cmp = Comparator.comparingInt(rule -> rule.pinned ? 0 : 1);
            cmp = cmp.thenComparing(rule -> rule.ssurtPrefixes().findFirst().orElse(""));
            cmp = cmp.thenComparingLong(rule -> rule.id);
            rules.sort(cmp);
        }
        if (sort.startsWith("-")) {
            Collections.reverse(rules);
        }

        // output format
        String type;
        String extension;
        RuleFormatter formatter;
        if (request.param("output", "json").equals("csv")) {
            type = "text/csv";
            extension = "csv";
            formatter = AccessRule::toCSV;
         } else {
            type = "application/json";
            extension = "json";
            formatter = AccessRule::toJSON;
        }
        Response response = new Response(OK, type, out -> {
            try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(out, UTF_8))) {
                formatter.format(rules, policyId -> index.accessControl.policy(policyId).name, bw);
            }
        });
        String filename = index.name.replace("\"", "") + "-access-rules." + extension;
        response.addHeader("Content-Disposition", "filename=\"" + filename + "\"");
        return response;
    }

    private interface RuleFormatter {
        void format(Collection<AccessRule> rules, Function<Long,String> policyNames, Writer out) throws IOException;
    }

    Response checkAccess(Web.Request request) throws IOException, ResponseException {
        String accesspoint = request.param("accesspoint");
        String url = request.mandatoryParam("url");
        String timestamp = request.mandatoryParam("timestamp");

        Date captureTime = Date.from(LocalDateTime.parse(timestamp, Capture.arcTimeFormat).toInstant(ZoneOffset.UTC));
        Date accessTime = new Date();

        return jsonResponse(getIndex(request).accessControl.checkAccess(accesspoint, url, captureTime, accessTime));
    }

    public static class AccessQuery {
        public String url;
        public String timestamp;
    }

    Response checkAccessBulk(Web.Request request) throws IOException, ResponseException {
        String accesspoint = request.param("accesspoint");
        Index index = getIndex(request);

        AccessQuery[] queries = fromJson(request, AccessQuery[].class);
        List<AccessDecision> responses = new ArrayList<>();

        for (AccessQuery query: queries) {
            Date captureTime = Date.from(LocalDateTime.parse(query.timestamp, Capture.arcTimeFormat).toInstant(ZoneOffset.UTC));
            Date accessTime = new Date();
            responses.add(index.accessControl.checkAccess(accesspoint, query.url, captureTime, accessTime));
        }

        return jsonResponse(responses);
    }

    @Override
    public Response handle(Web.Request request) throws Exception {
        if (!request.path().startsWith(request.contextPath() + "/")) {
            return redirect(request.contextPath() + "/");
        }
        Response response = router.handle(request);
        if (response != null) {
            response.addHeader("Access-Control-Allow-Origin", "*");
        }
        return response;
    }

}