package act.test;

/*-
 * #%L
 * ACT Framework
 * %%
 * Copyright (C) 2014 - 2019 ActFramework
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */

import act.Act;
import act.app.App;
import act.metric.Metric;
import act.metric.MetricInfo;
import act.metric.Timer;
import act.test.func.Func;
import act.test.util.*;
import act.test.verifier.Verifier;
import act.util.LogSupport;
import act.util.ProgressGauge;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import okhttp3.*;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.osgl.$;
import org.osgl.exception.UnexpectedException;
import org.osgl.util.*;

import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

import static act.test.Test.PG_PAYLOAD_FAILED;
import static act.test.util.ErrorMessage.errorIf;
import static act.test.util.ErrorMessage.errorIfNot;

/**
 * A single test session encapsulate a full cycle of test run including
 *
 * * clear fixture
 * * run dependency scenarios
 * * run target scenario
 *
 * For each scenario run it contains:
 *
 * * load fixtures
 * * run scenario
 *
 */
public class TestSession extends LogSupport {

    private static ThreadLocal<TestSession> current = new ThreadLocal<>();

    static TestSession current() {
        return current.get();
    }

    private boolean proceed;
    private Scenario running;
    private Scenario target;
    private List<Scenario> dependencies = new ArrayList<>();
    private App app;
    private int port = 5460;
    private OkHttpClient http;
    private CookieStore cookieStore;
    private transient Metric metric = Act.metricPlugin().metric(MetricInfo.ACT_TEST_SCENARIO);

    $.Var<Object> lastData = $.var();
    $.Var<Headers> lastHeaders = $.var();
    public Map<String, Object> constants = new HashMap<>();

    Map<String, Object> cache = new HashMap<>();

    public RequestTemplateManager requestTemplateManager;

    public TestSession(Scenario scenario, RequestTemplateManager requestTemplateManager) {
        this.requestTemplateManager = requestTemplateManager;
        dependencies.addAll(scenario.allDepends);
        target = scenario;
        app = Act.app();
        if (null != app) {
            port = app.config().httpPort();
        }
    }

    public void cache(String name, Object payload) {
        cache.put(name, payload);
    }

    public Object cached(String name) {
        return cache.get(name);
    }

    public int port() {
        return port;
    }

    public void run(ProgressGauge gauge) {
        current.set(this);
        prepareHttp();
        gauge.incrMaxHintBy(dependencies.size() + 2);
        reset();
        gauge.step();
        proceed = runAll(gauge, dependencies);
        if (proceed) {
            proceed = runOne(target, gauge);
        }
        gauge.step();
    }

    private boolean reset() {
        Collections.sort(dependencies, new ScenarioComparator(target.partition));
        for (Scenario scenario : dependencies) {
            scenario.reset();
        }
        target.reset();
        clearSession();
        return clearFixtures();
    }

    public void clearSession() {
        cookieStore().clear();
    }

    public boolean clearFixtures() {
        if (isTraceEnabled()) {
            trace("clear fixture for " + target.name);
        }
        Timer timer = metric.startTimer("clear-fixtures");
        try {
            return verify(RequestSpec.RS_CLEAR_FIXTURE, "clearing fixtures");
        } finally {
            timer.stop();
        }
    }

    private boolean runAll(ProgressGauge gauge, List<Scenario> scenarios) {
        boolean proceed = true;
        for (Scenario scenario : scenarios) {
            proceed = proceed && runOne(scenario, gauge);
            gauge.step();
            if (!proceed) {
                break;
            }
        }
        return proceed;
    }

    private boolean runOne(Scenario scenario, ProgressGauge gauge) {
        gauge.clearPayload();
        gauge.setPayload(Test.PG_PAYLOAD_SCENARIO, scenario.title());
        boolean okay;
        try {
            running = $.requireNotNull(scenario);
            scenario.validate(this);
            constants.putAll(running.constants);
            okay = createFixtures() && scenario.run(this, gauge);
            scenario.status = TestStatus.of(okay);
        } catch (RuntimeException e) {
            okay = false;
            String message = e.getMessage();
            scenario.errorMessage = S.blank(message) ? e.getClass().getName() : message;
            scenario.cause = e.getCause();
            scenario.status = TestStatus.FAIL;
        }
        gauge.setPayload(PG_PAYLOAD_FAILED, !scenario.status.pass());
        return okay;
    }

    Response sendRequest(RequestSpec req) throws IOException {
        Timer timer = metric.startTimer("build-request");
        Request httpRequest = new RequestBuilder(req, this, port).build();
        timer.stop();
        timer = metric.startTimer("send-request");
        Response resp = http.newCall(httpRequest).execute();
        timer.stop();
        lastHeaders.set(resp.headers());
        return resp;
    }

    private boolean createFixtures() {
        if (running.fixtures.isEmpty()) {
            return true;
        }
        Timer timer = metric.startTimer("create-fixtures");
        try {
            RequestSpec req = RequestSpec.loadFixtures(running.fixtures);
            return verify(req, "creating fixtures");
        } finally {
            timer.stop();
        }
    }


    void verifyDownloadChecksum(Response response, String checksumExpected) {
        if (checksumExpected.startsWith("${")) {
            checksumExpected = S.string(evalStr(checksumExpected));
        }
        Object o = cached(checksumExpected);
        if (null == o) {
            o = cached("checksum-last");
        }
        if (null == o) {
            o = checksumExpected;
        }
        ResponseBody body = response.body();
        errorIf(null == body, "No download found");
        String downloadChecksum = IO.checksum(body.byteStream());
        errorIfNot($.eq(downloadChecksum, o), "Download checksum not match");
    }

    void verifyDownloadFilename(Response response, String filenameExpected) {
        String header = response.header("Content-Disposition");
        // header should be something like: attachment; filename="postcodes.xls"
        errorIf(S.isBlank(header), "No Content-Disposition header found");
        int pos = header.indexOf('"');
        errorIf(pos < 0, "Content-Disposition header does not have filename specified: " + header);
        String filename = header.substring(pos + 1);
        filename = filename.substring(0, filename.length() - 1); // strip off last `"`
        errorIfNot(S.eq(filenameExpected, filename), "Download filename[%s] not match expected value[%s]", filename, filenameExpected);
    }

    Scenario scenario() {
        return null == running ? target : running;
    }

    boolean verify(RequestSpec req, String operation) {
        boolean pass = true;
        Response resp = null;
        try {
            resp = sendRequest(req);
            if (!resp.isSuccessful()) {
                pass = false;
                scenario().errorMessage = "Fixtures loading failure";
            }
            return pass;
        } catch (IOException e) {
            scenario().errorMessage = "Error " + operation;
            ErrorMessage.error(e, scenario().errorMessage);
            return false;
        } finally {
            IO.close(resp);
        }
    }

    private void prepareHttp() {
        if (isTraceEnabled()) {
            trace("prepareHTTP for scenario: " + target.name);
        }
        long timeout = app.config().testTimeout();
        http = new OkHttpClient.Builder()
                .cookieJar(cookieStore())
                .connectTimeout(timeout, TimeUnit.SECONDS)
                .readTimeout(timeout, TimeUnit.SECONDS)
                .writeTimeout(timeout, TimeUnit.SECONDS)
                .build();
    }

    private synchronized CookieStore cookieStore() {
        if (null == cookieStore) {
            App app = Act.app();
            cookieStore = null == app ? new CookieStore() : app.getInstance(CookieStore.class);
        }
        return cookieStore;
    }

    String processStringSubstitution(String s) {
        int n = s.indexOf("${");
        if (n < 0) {
            return s;
        }
        if (n == 0 && s.endsWith(")}")) {
            s = s.substring(2);
            s = s.substring(0, s.length() - 1);
            return S.string(evalFunc(s));
        }
        int a = 0;
        int z = n;
        S.Buffer buf = S.buffer();
        while (true) {
            buf.append(s.substring(a, z));
            n = s.indexOf("}", z);
            a = n;
            String part = s.substring(z + 2, a);
            if (part.contains("(") && part.endsWith(")")) {
                buf.append(evalFunc(part));
            } else {
                String key = part;
                String payload = "";
                if (part.contains(":")) {
                    S.Binary binary = S.binarySplit(part, ':');
                    key = binary.first();
                    payload = binary.second();
                }
                buf.append(getVal(key, payload));
            }
            n = s.indexOf("${", a);
            if (n < 0) {
                buf.append(s.substring(a + 1));
                return buf.toString();
            }
            z = n;
        }
    }

    Object evalFunc(String funcExpr) {
        String funcName = funcExpr;
        List<String> vals = C.list();
        if (funcExpr.contains("(")) {
            funcName = S.cut(funcExpr).beforeFirst("(");
            String paramStr = S.cut(funcExpr).afterFirst("(");
            paramStr = S.cut(paramStr).beforeLast(")");
            if (S.notBlank(paramStr)) {
                if (S.is(paramStr).wrappedWith(S.DOUBLE_QUOTES) || S.is(paramStr).wrappedWith(S.SINGLE_QUOTES)) {
                    paramStr = paramStr.substring(1, paramStr.length() - 1);
                    vals = C.list(paramStr);
                } else {
                    vals = C.newList(S.fastSplit(paramStr, ","));
                    for (int i = 0; i < vals.size(); ++i) {
                        String val = S.ensure(vals.get(i).trim()).strippedOff(S.DOUBLE_QUOTES);
                        val = processStringSubstitution(val);
                        vals.set(i, val);
                    }
                }
            }
        }
        Func func = $.convert(funcName).to(Func.class);
        switch (vals.size()) {
            case 0:
                break;
            case 1:
                func.init(vals.get(0));
                break;
            default:
                func.init(vals);
        }
        return func.apply();
    }

    Object eval(String expr) {
        if (expr.startsWith("func:")) {
            return evalFunc(expr.substring(5));
        } else if (expr.contains("(")) {
            return evalFunc(expr);
        }
        String key = S.underscore(expr);
        Object o = constants.get(key);
        o = null == o ? cache.get(key) : o;
        o = null == o ? Test.constant(key) : o;
        try {
            // final try - treat it as a function
            return null == o ? evalFunc(expr) : o;
        } catch (IllegalArgumentException e) {
            return null;
        }
    }

    Object evalStr(String s) {
        int n = s.indexOf("${");
        if (n < 0) {
            return s;
        }
        if (n > 0) {
            return processStringSubstitution(s);
        }
        int z = s.indexOf("}");
        if (z < s.length() - 1) {
            return processStringSubstitution(s);
        }
        String part = s.substring(2, z);
        if (part.contains("(") && part.endsWith(")")) {
            return evalFunc(part);
        } else {
            String key = part;
            String payload = "";
            if (part.contains(":")) {
                S.Binary binary = S.binarySplit(part, ':');
                key = binary.first();
                payload = binary.second();
            }
            return getVal(key, payload);
        }
    }


    private Object getVal(String key) {
        if ("last".equals(key)) {
            return lastData.get();
        }
        Object o = cache.get(key);
        if (null != o) {
            return o;
        }
        if (key.contains(".")) {
            String firstLevel = S.cut(key).beforeFirst(".");
            Object firstLevelVal = cache.get(firstLevel);
            if (null != firstLevelVal) {
                try {
                    return $.getProperty(firstLevelVal, S.cut(key).afterFirst("."));
                } catch (Exception e) {
                    // ignore
                }
            }
        }
        String underscoredKey = S.underscore(key);
        o = constants.get(underscoredKey);
        if (null != o) {
            return o;
        }
        o = Test.constant(underscoredKey);
        if (null != o) {
            return o;
        }
        try {
            return evalFunc(key);
        } catch (Exception e) {
            try {
                return getLastVal(key);
            } catch (Exception e1) {
                throw E.unexpected("Unable to get value by key: %s", key);
            }
        }
    }

    private Object getVal(String key, String ref) {
        Object stuff = getVal(key);
        if (S.blank(ref) || null == stuff) {
            return stuff;
        }
        if (stuff instanceof String) {
            String str = $.cast(stuff);
            if (str.contains("<") && str.contains(">")) {
                // try get html element
                try {
                    Document doc = Jsoup.parse(str, S.concat("http://localhost:", port, "/"));
                    Elements elements = doc.select(ref);
                    if (elements.size() != 0) {
                        return elements.get(0).text();
                    }
                } catch (Exception e) {
                    // just ignore
                }
            }
        }
        return JSONTraverser.traverse(stuff, ref);
    }

    Object getLastVal(String ref) {
        return getVal("last", ref);
    }

    void verifyBody(String bodyString, ResponseSpec spec) {
        lastData.set(bodyString);
        if (null == spec) {
            if (bodyString.startsWith("[")) {
                JSONArray array = JSON.parseArray(bodyString);
                lastData.set(array);
            } else if (bodyString.startsWith("{")) {
                JSONObject obj = JSON.parseObject(bodyString);
                lastData.set(obj);
            }
            return;
        }
        if (null != spec.text) {
            verifyValue("body text", bodyString, spec.text);
        } else if (null != spec.json && !spec.json.isEmpty()) {
            if (bodyString.startsWith("[")) {
                JSONArray array = JSON.parseArray(bodyString);
                lastData.set(array);
                verifyList("body json array", array, spec.json);
            } else if (bodyString.startsWith("{")) {
                JSONObject obj = JSON.parseObject(bodyString);
                lastData.set(obj);
                verifyJsonObject(obj, spec.json);
            } else {
                ErrorMessage.error("Unknown JSON string: \n%s", bodyString);
            }
        } else if (null != spec.xml && !spec.xml.isEmpty()) {
            org.w3c.dom.Document doc = XML.read(bodyString);
            JSONObject obj = $.convert(doc).to(JSONObject.class);
            String xmlRoot = app.config().xmlRootTag();
            if (obj.containsKey(xmlRoot)) {
                Object o = obj.get(xmlRoot);
                lastData.set(o);
                if (o instanceof JSONObject) {
                    obj = (JSONObject) o;
                    verifyJsonObject(obj, spec.xml);
                } else if (o instanceof List) {
                    verifyList("body xml array", (List)o, spec.xml);
                } else {
                    throw new UnexpectedException("Unknown root type: " + o.getClass());
                }
            } else {
                lastData.set(obj);
                verifyJsonObject(obj, spec.xml);
            }
        } else if (null != spec.html && !spec.html.isEmpty()) {
            lastData.set(bodyString);
            Document doc = Jsoup.parse(bodyString, S.concat("http://localhost:", port, "/"));
            for (Map.Entry<String, Object> entry : spec.html.entrySet()) {
                String path = entry.getKey();
                Elements elements = doc.select(path);
                verifyValue("body html path", elements, entry.getValue());
            }
        }
    }

    void verifyList(String name, List array, Map spec) {
        for (Object obj : spec.entrySet()) {
            Map.Entry entry = $.cast(obj);
            Object key = entry.getKey();
            String sKey = S.string(key);
            Object test = entry.getValue();
            Object value = null;
            if ("me".equals(key) || "this".equals(key)) {
                value = array;
            } else if ("size".equals(key) || "len".equals(key) || "length".equals(key)) {
                value = array.size();
            } else if ("toString".equals(key) || "string".equals(key) || "str".equals(key)) {
                value = JSON.toJSONString(array);
            } else if ("?".equals(key) || (sKey.toLowerCase().startsWith("<any") && sKey.toLowerCase().endsWith(">"))) {
                for (Object arrayElement : array) {
                    try {
                        verifyValue(name, arrayElement, test);
                        return;
                    } catch (Exception e) {
                        // try next one
                    }
                }
                throw ErrorMessage.error("No element matches requirement: %s", test);
            } else if (S.isInt(sKey)) {
                int id = Integer.parseInt(sKey);
                value = array.get(id);
            } else {
                if (sKey.contains(".")) {
                    String id = S.cut(key).beforeFirst(".");
                    String prop = S.cut(key).afterFirst(".");
                    if ("?".equals(id) || "<any>".equalsIgnoreCase(id)) {
                        for (Object arrayElement : array) {
                            if (!(arrayElement instanceof JSONObject)) {
                                continue;
                            }
                            try {
                                verifyValue(prop, ((JSONObject) arrayElement).get(prop), test);
                                return;
                            } catch (Exception e) {
                                // try next one
                            }
                        }
                    } else if (S.isInt(id)) {
                        int i = Integer.parseInt(id);
                        Object o = array.get(i);
                        if (o instanceof JSONObject) {
                            JSONObject json = (JSONObject) o;
                            value = json.get(prop);
                        }
                    }
                }
                if (null == value) {
                    throw ErrorMessage.error("Unknown attribute of array verification: %s", key);
                }
            }
            verifyValue(name, value, test);
        }
    }

    void verifyJsonObject(JSONObject obj, Map<String, Object> jsonSpec) {
        for (Map.Entry<String, Object> entry : jsonSpec.entrySet()) {
            String key = entry.getKey();
            // ${sizeOf(date)}
            if (key.startsWith("${")) {
                // sizeOf(date)}
                String s = key.substring(2);

                // sizeOf(date)
                s = s.substring(0, s.length() - 1);

                // sizeOf
                String funcName = s.substring(0, s.indexOf("("));

                // date)
                s = s.substring(s.indexOf("(") + 1);
                // date
                String varName = s.substring(0, s.length() - 1);

                Object val = $.getProperty(obj, varName);
                if (val instanceof CharSequence) {
                    val = S.wrap(val).with(S.DOUBLE_QUOTES);
                }
                val = evalFunc(funcName + "(" + val + ")");
                verifyValue(key, val, entry.getValue());
            } else {
                Object val = $.getProperty(obj, key);
                verifyValue(key, val, entry.getValue());
            }
        }
    }

    void verifyValue(String name, Object value, Object test) {
        if (test instanceof List) {
            verifyValue_(name, value, (List) test);
        } else if (value instanceof List && test instanceof Map) {
            verifyList(name, (List) value, (Map) test);
        } else {
            if (matches(value, test)) {
                return;
            }
            if (value instanceof JSONObject) {
                errorIfNot(test instanceof Map, "Cannot verify %s value [%s] against test [%s]", name, value, test);
                JSONObject json = (JSONObject) value;
                Map<String, ?> testMap = (Map) test;
                for (Map.Entry<?, ?> entry : testMap.entrySet()) {
                    Object testKey = entry.getKey();
                    Object testValue = entry.getValue();
                    Object attr = json.get(testKey);
                    verifyValue(S.concat(name, ".", testKey), attr, testValue);
                }
            } else if (value instanceof Elements) {
                if (test instanceof Map) {
                    verifyList(name, (Elements) value, (Map) test);
                } else {
                    Elements elements = (Elements) value;
                    if (elements.isEmpty()) {
                        value = null;
                    } else {
                        value = elements.first();
                    }
                    verifyValue(name, value, test);
                }
            } else if (value instanceof Number) {
                Number found = (Number) value;
                Number expected = null;
                if (test instanceof Number) {
                    expected = (Number) test;
                } else {
                    String s = S.string(test);
                    s = S.isNumeric(s) ? s : processStringSubstitution(s);
                    if (S.isNumeric(S.string(s))) {
                        expected = $.convert(s).to(Double.class);
                    } else {
                        ErrorMessage.error("Cannot verify %s value [%s] against test [%s]", name, value, test);
                    }
                }
                double delta = Math.abs(expected.doubleValue() - found.doubleValue());
                if ((delta / found.doubleValue()) > 0.001) {
                    ErrorMessage.error("Cannot verify %s value [%s] against test [%s]", name, value, test);
                }
            } else {
                // try convert the test into String
                String testString = $.convert(test).toString();
                boolean verified = verifyStringValue_(testString, value, test);
                if (!verified) {
                    String processedString = processStringSubstitution(testString);
                    if (S.neq(processedString, testString)) {
                        verified = verifyStringValue_(processedString, value, test);
                    }
                }
                errorIfNot(verified, "Cannot verify %s value [%s] against test [%s]", name, value, test);
            }
        }
    }

    private boolean verifyStringValue_(String testString, Object value, Object test) {
        if (matches(testString, value)) {
            return true;
        }
        if (null != value && ("*".equals(test) || "...".equals(test) || "<any>".equals(test))) {
            return true;
        }
        try {
            Pattern p = Pattern.compile(testString);
            return p.matcher(S.string(value)).matches();
        } catch (Exception e) {
            // ignore
        }
        Verifier v = tryLoadVerifier(testString);
        if (null != v && v.verify(value)) {
            return true;
        }
        return false;
    }

    private void verifyValue_(String name, Object value, List tests) {
        // try to do the literal match
        if (value instanceof List) {
            List found = (List) value;
            boolean ok = found.size() == tests.size();
            if (ok) {
                for (int i = 0; i < found.size(); ++i) {
                    Object foundElement = found.get(i);
                    Object testElement = tests.get(i);
                    if (!matches(foundElement, testElement)) {
                        ok = false;
                        break;
                    }
                }
            }
            if (ok) {
                return;
            }
        }
        // now try verifiers
        if (value instanceof Elements) {
            Elements elements = (Elements) value;
            if (elements.size() > 0) {
                value = elements.first();
            } else {
                value = null;
            }
        }
        for (Object test : tests) {
            errorIfNot(test instanceof Map, "Cannot verify %s value [%s] against test [%s]", name, value, test);
            Map<?, ?> map = (Map) test;
            errorIfNot(map.size() == 1, "Cannot verify %s value [%s] against test [%s]", name, value, test);
            Map.Entry entry = map.entrySet().iterator().next();
            Object entryValue = entry.getValue();
            if (entryValue instanceof String) {
                String s = (String) entryValue;
                Object processed = evalStr(s);
                if (s != processed) {
                    entry.setValue(processed);
                }
            }
            Verifier v = $.convert(map).to(Verifier.class);
            errorIf(null == v, "Cannot verify %s value [%s] against test [%s]", name, value, test);
            errorIf(!verify(v, value), "Cannot verify %s value [%s] against test [%s]", name, value, v);
        }
    }

    private boolean verify(Verifier test, Object value) {
        if (test.verify(value)) {
            return true;
        }
        if (value instanceof Element) {
            Element e = (Element) value;
            if (test.verify(e.val())) {
                return true;
            }
            if (test.verify(e.text())) {
                return true;
            }
            if (test.verify(e.html())) {
                return true;
            }
        }
        return false;
    }

    private static boolean matches(Object a, Object b) {
        if ($.eq(a, b)) {
            return true;
        }
        if (!((b instanceof String) && (a instanceof Element))) {
            return false;
        }
        String test = S.string(b);
        Element element = (Element) a;
        // try html
        String html = element.html();
        if (S.eq(html, test, S.IGNORECASE)) {
            return true;
        }
        // try text
        String text = element.text();
        if (S.eq(text, test, S.IGNORECASE)) {
            return true;
        }
        // try val
        String val = element.val();
        if (S.eq(val, test, S.IGNORECASE)) {
            return true;
        }
        return false;
    }

    private Class<?> tryLoadClass(String name) {
        try {
            return null != app ? app.classForName(name) : $.classForName(name);
        } catch (Exception e) {
            return null;
        }
    }

    private Verifier tryLoadVerifier(String name) {
        boolean revert = false;
        if (name.startsWith("!") || name.startsWith("-")) {
            revert = true;
            name = name.substring(1).trim();
        } else if (name.startsWith("not:") || name.startsWith("not ")) {
            revert = true;
            name = name.substring(4).trim();
        }
        Class<?> c = tryLoadClass(name);
        if (null != c) {
            if (Verifier.class.isAssignableFrom(c)) {
                final Verifier v = (Verifier) (null != app ? app.getInstance(c) : $.newInstance(c));
                return v.meOrReversed(revert);
            } else {
                throw new UnexpectedException("Class not supported: " + name);
            }
        }
        return null;
    }

}
