package com.ascendix.jdbc.salesforce.delegates;

import com.ascendix.jdbc.salesforce.metadata.Column;
import com.ascendix.jdbc.salesforce.metadata.Table;
import com.ascendix.jdbc.salesforce.statement.FieldDef;
import com.sforce.soap.partner.DescribeGlobalResult;
import com.sforce.soap.partner.DescribeGlobalSObjectResult;
import com.sforce.soap.partner.DescribeSObjectResult;
import com.sforce.soap.partner.Field;
import com.sforce.soap.partner.PartnerConnection;
import com.sforce.soap.partner.QueryResult;
import com.sforce.ws.ConnectionException;
import com.sforce.ws.bind.XmlObject;
import org.apache.commons.collections4.IteratorUtils;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;

public class PartnerService {

    private PartnerConnection partnerConnection;
    private List<String> sObjectTypesCache;

    public PartnerService(PartnerConnection partnerConnection) {
        this.partnerConnection = partnerConnection;
    }

    public List<Table> getTables() {
        List<DescribeSObjectResult> sObjects = getSObjectsDescription();
        return sObjects.stream()
                .map(this::convertToTable)
                .collect(Collectors.toList());
    }

    public DescribeSObjectResult describeSObject(String sObjectType) throws ConnectionException {
        return partnerConnection.describeSObject(sObjectType);
    }

    private Table convertToTable(DescribeSObjectResult so) {
        List<Field> fields = Arrays.asList(so.getFields());
        List<Column> columns = fields.stream()
                .map(this::convertToColumn)
                .collect(Collectors.toList());
        return new Table(so.getName(), null, columns);
    }

    private Column convertToColumn(Field field) {
        try {
            Column column = new Column(field.getName(), getType(field));
            column.setNillable(false);
            column.setCalculated(field.isCalculated() || field.isAutoNumber());
            String[] referenceTos = field.getReferenceTo();
            if (referenceTos != null) {
                for (String referenceTo : referenceTos) {
                    if (getSObjectTypes().contains(referenceTo)) {
                        column.setReferencedTable(referenceTo);
                        column.setReferencedColumn("Id");
                    }
                }
            }
            return column;
        } catch (ConnectionException e) {
            throw new RuntimeException(e);
        }
    }

    private String getType(Field field) {
        String s = field.getType().toString();
        if (s.startsWith("_")) {
            s = s.substring("_".length());
        }
        return s.equalsIgnoreCase("double") ? "decimal" : s;
    }

    private List<String> getSObjectTypes() throws ConnectionException {
        if (sObjectTypesCache == null) {
            DescribeGlobalSObjectResult[] sobs = partnerConnection.describeGlobal().getSobjects();
            sObjectTypesCache = Arrays.stream(sobs)
                    .map(DescribeGlobalSObjectResult::getName)
                    .collect(Collectors.toList());
        }
        return sObjectTypesCache;

    }

    private List<DescribeSObjectResult> getSObjectsDescription() {
        DescribeGlobalResult describeGlobals = describeGlobal();
        List<String> tableNames = Arrays.stream(describeGlobals.getSobjects())
                .map(DescribeGlobalSObjectResult::getName)
                .collect(Collectors.toList());
        List<List<String>> tableNamesBatched = toBatches(tableNames, 100);
        return tableNamesBatched.stream()
                .flatMap(batch -> describeSObjects(batch).stream())
                .collect(Collectors.toList());
    }

    private DescribeGlobalResult describeGlobal() {
        try {
            return partnerConnection.describeGlobal();
        } catch (ConnectionException e) {
            throw new RuntimeException(e);
        }
    }

    private List<DescribeSObjectResult> describeSObjects(List<String> batch) {
        DescribeSObjectResult[] result;
        try {
            result = partnerConnection.describeSObjects(batch.toArray(new String[0]));
            return Arrays.asList(result);
        } catch (ConnectionException e) {
            throw new RuntimeException(e);
        }
    }

    private <T> List<List<T>> toBatches(List<T> objects, int batchSize) {
        List<List<T>> result = new ArrayList<>();
        for (int fromIndex = 0; fromIndex < objects.size(); fromIndex += batchSize) {
            int toIndex = Math.min(fromIndex + batchSize, objects.size());
            result.add(objects.subList(fromIndex, toIndex));
        }
        return result;
    }

    public List<List> query(String soql, List<FieldDef> expectedSchema) throws ConnectionException {
        List<List> resultRows = Collections.synchronizedList(new LinkedList<>());
        QueryResult queryResult = null;
        do {
            queryResult = queryResult == null ? partnerConnection.query(soql)
                    : partnerConnection.queryMore(queryResult.getQueryLocator());
            resultRows.addAll(removeServiceInfo(Arrays.asList(queryResult.getRecords())));
        } while (!queryResult.isDone());

        return PartnerResultToCrtesianTable.expand(resultRows, expectedSchema);
    }

    private List<List> removeServiceInfo(Iterator<XmlObject> rows) {
        return removeServiceInfo(IteratorUtils.toList(rows));
    }

    private List<List> removeServiceInfo(List<XmlObject> rows) {
        return rows.stream()
                .filter(this::isDataObjectType)
                .map(this::removeServiceInfo)
                .collect(Collectors.toList());
    }

    private List removeServiceInfo(XmlObject row) {
        return IteratorUtils.toList(row.getChildren()).stream()
                .filter(this::isDataObjectType)
                .skip(1) // Removes duplicate Id from SF Partner API response
                // (https://developer.salesforce.com/forums/?id=906F00000008kciIAA)
                .map(field -> isNestedResultset(field)
                        ? removeServiceInfo(field.getChildren())
                        : toForceResultField(field))
                .collect(Collectors.toList());
    }

    private ForceResultField toForceResultField(XmlObject field) {
        String fieldType = field.getXmlType() != null ? field.getXmlType().getLocalPart() : null;
        if ("sObject".equalsIgnoreCase(fieldType)) {
            List<XmlObject> children = new ArrayList<>();
            field.getChildren().forEachRemaining(children::add);
            field = children.get(2);
        }
        String name = field.getName().getLocalPart();
        Object value = field.getValue();
        return new ForceResultField(null, fieldType, name, value);
    }

    private boolean isNestedResultset(XmlObject object) {
        return object.getXmlType() != null && "QueryResult".equals(object.getXmlType().getLocalPart());
    }

    private final static List<String> SOAP_RESPONSE_SERVICE_OBJECT_TYPES = Arrays.asList("type", "done", "queryLocator",
            "size");

    private boolean isDataObjectType(XmlObject object) {
        return !SOAP_RESPONSE_SERVICE_OBJECT_TYPES.contains(object.getName().getLocalPart());
    }
}