/*
 * -----------------------------------------------------------------------------
 * COPYRIGHT (C) 2019, FONTEVA, INC.
 * ALL RIGHTS RESERVED.
 *
 * ALL INFORMATION CONTAINED HEREIN IS, AND REMAINS THE PROPERTY OF FONTEVA
 * INCORPORATED AND ITS SUPPLIERS, IF ANY. THE INTELLECTUAL AND TECHNICAL
 * CONCEPTS CONTAINED HEREIN ARE PROPRIETARY TO FONTEVA INCORPORATED AND
 * ITS SUPPLIERS AND MAY BE COVERED BY U.S. AND FOREIGN PATENTS, PATENTS IN
 * PROCESS, AND ARE PROTECTED BY TRADE SECRET OR COPYRIGHT LAW. DISSEMINATION
 * OF THIS INFORMATION OR REPRODUCTION OF THIS MATERIAL IS STRICTLY FORBIDDEN
 * UNLESS PRIOR WRITTEN PERMISSION IS OBTAINED FROM FONTEVA, INC.
 * -----------------------------------------------------------------------------
 */

public with sharing class PfmSelector {
    public class pfmSelectorException extends Exception {
    }

    static String language = null;
    public String fieldsToString;

    public FieldListBuilder listBuilder;
    public SObjectType type;
    public List<FieldSet> fieldSets;
    public DescribeFieldResult lookupFieldDescribe;
    //    public Registered_Object__c config;
    public String sobjectName;
    public String soqlFilter;
    public String soqlWhereClause;
    public String soqlLimit;
    public String soqlSort;
    public Integer soqlOffset;
    public Boolean hasFilter;
    public Boolean enableTranslation;
    public Boolean enableForUpdateClause = false;

    public Object substitution = null;

    public static List<SObject> parseRun(String soql, Object substitution) {
        return parseRun(soql, substitution, false);
    }

    public static List<SObject> parseRunTranslate(String soql, Object substitution) {
        return parseRun(soql, substitution, true);
    }

    private Boolean canOptimizeTranslation(Set<Id> idSet, SObjectType objType) {
        List<String> relationFields = PfmSetUtils.filterStrings(this.fieldsToString.split(','), '.*\\..*');
        List<String> relationObjects = PfmSetUtils.mapToLower(
            PfmSetUtils.trim(PfmSetUtils.filterNotEmpty(PfmSetUtils.mapStrings(relationFields, '(.*)\\..*', '$1')))
        );
        // massage fieldsToString to include Id fields of any relation fields
        for (String relation : relationObjects) {
            if (relation.contains('.')) {
                continue; // double-lookup properties are causing an issue - just skip and forget translating them for now
            }
            if (!this.fieldsToString.contains(relation + '.id')) {
                this.fieldsToString += ',' + relation + '.id';
            }
        }
        // if the where clause contains an OR, bail out of the optimization
        // if it has an AND, worst case is we query too many rows from the translation table
        // let's still take the optimization to (hopefully) query less rows
        return idSet != null &&
            objType == this.type &&
            relationFields.isEmpty() &&
            (this.soqlWhereClause == null ||
            (this.soqlWhereClause.contains('Id =') || this.soqlWhereClause.contains('Id IN')) &&
            !this.soqlWhereClause.contains(' or '));
    }

    private Set<Id> generateTranslationKeyset(List<SObject> objects) {
        List<String> relationFields = PfmSetUtils.filterStrings(this.fieldsToString.split(','), '.*\\..*');
        List<String> idFields = PfmSetUtils.filterStrings(PfmSetUtils.mapToLower(relationFields), '(.*)\\.id$');

        Set<Id> retVal = PfmSetUtils.getIds(objects);
        if (relationFields.isEmpty()) {
            return retVal;
        }

        // find all the Id fields for child objects; we need to query the translation table for them as well
        // subqueries are handled in their own pfmSelector instance
        for (String idField : idFields) {
            retVal.addAll(PfmSetUtils.asIds(PfmSetUtils.mapToRelatedField(objects, idField)));
        }

        return retVal;
    }

    public static List<SObject> parseRun(String soql, Object substitution, Boolean enableTranslation) {
        // you're tempted to use String.format here, but Apex is dumb: https://developer.salesforce.com/forums/?id=906F00000008yzsIAA
        // tl;dr; in String.format, a single quote is \'\'

        PfmSelector s = PfmSelector.parse(soql);
        s.substitution = substitution;
        s.enableTranslation = enableTranslation;
        if (substitution == null && !soql.contains(':val')) {
            return s.runQuery();
        } else if (s.hasSingleSubstitution()) {
            return s.runQuery();
        } else if (substitution instanceof List<Object>) {
            return s.runQuery();
        } else {
            throw new QueryException('Unsupported argument passed to parseRun');
        }
    }

    public Boolean hasSingleSubstitution() {
        return substitution instanceof String ||
            substitution instanceof Boolean ||
            substitution instanceof Datetime ||
            substitution instanceof Date ||
            substitution instanceof Decimal ||
            substitution instanceof Id ||
            substitution instanceof Set<String> ||
            substitution instanceof List<String> ||
            substitution instanceof Set<Id> ||
            substitution instanceof List<Id> ||
            substitution == null && soqlWhereClause != null && soqlWhereClause.contains(':val');
    }

    List<SObject> runQuery() {
        return runQuery(null, null);
    }

    List<SObject> runQuery(Set<Id> idSet, String referenceFieldName) {
        String queryStr = buildQueryStatement(referenceFieldName)
            .replace('{0}', ':val')
            .replace('{1}', ':val1')
            .replace('{2}', ':val2')
            .replace('{3}', ':val3')
            .replace('{4}', ':val4');
        if (!hasSingleSubstitution() && substitution instanceof List<Object>) {
            List<Object> params = (List<Object>) substitution;
            if (params.size() > 5) {
                throw new QueryException('Only 5 query params are supported at the moment');
            }
            Object val = params.get(0);
            Object val1 = params.size() < 2 ? null : params.get(1);
            Object val2 = params.size() < 3 ? null : params.get(2);
            Object val3 = params.size() < 4 ? null : params.get(3);
            Object val4 = params.size() < 5 ? null : params.get(4);
            return Database.query(queryStr);
        } else {
            Object val = substitution;
            return Database.query(queryStr);
        }
    }

    private static PfmSelector parse(String soql, Boolean subquery, SObjectType parentType) {
        Pattern p = Pattern.compile(
            '(?i)select\\s+(.*?)\\s*from\\s+(.*?)\\s*(where\\s(.*?))?\\s*(order\\s+by\\s+(.*?))?\\s*(limit\\s+(.*?))?'
        ); //\\s+[ASC|DESC]
        Matcher m = p.matcher(soql);
        if (m.matches()) {
            String fields = m.group(1);
            String objectStr = m.group(2);
            String whereClause = m.group(4);
            SObjectType objType = PfmSchemaService.getSObjectType(objectStr);
            ChildRelationship cr = null;
            if (objType == null && subquery) {
                for (ChildRelationship rel : parentType.getDescribe().getChildRelationships()) {
                    if (rel.getRelationshipName() == objectStr) {
                        cr = rel;
                        objType = rel.getChildSObject();
                    }
                }
            }
            PfmSelector s = new PfmSelector(objType);
            Pattern limitPattern = Pattern.compile('(?i)limit\\s(.*)');
            if (m.group(6) != null) {
                s.soqlSort = m.group(6);
            }
            if (m.group(7) != null) {
                Matcher limitMatch = limitPattern.matcher(m.group(7));
                if (limitMatch.matches()) {
                    s.soqlLimit = limitMatch.group(1);
                } else {
                    throw new QueryException('Can\'t parse query: ' + soql);
                }
            }
            if (fields.contains('*') && fields.contains(',')) {
                // to support related items, you can do something like
                // Select *, Account.Name From Contact
                List<String> nonStarFields = PfmSetUtils.filterStrings(fields.split(','), '.*[A-Za-z_\\.].*');
                s.fieldsToString += ',' + String.join(PfmSetUtils.trim(nonStarFields), ',');
            } else if (fields != '*') {
                s.fields(fields);
            }
            s.soqlWhereClause = whereClause;
            return s;
        }
        throw new QueryException('Can\'t parse query: ' + soql);
    }

    public static PfmSelector parse(String soql) {
        return parse(soql, false, null);
    }

    /**
     * @see buildQuery(String)
     * @return String SOQL statement in string form
     */
    public String buildQueryStatement() {
        return buildQueryStatement(null);
    }

    /**
     * Returns SOQL template in string form
     * @return String
     */
    public String buildQueryStatement(String referenceFieldName) {
        String template = 'SELECT {0} {1} FROM {2} {3} ORDER BY {4} LIMIT {5} {6} {7}';
        List<String> vars = new List<String>();
        vars.add(fieldsToString);
        vars.add(buildRelationshipQuery());
        vars.add(sobjectName);
        vars.add(getWhereClause(referenceFieldName));
        vars.add(getOrderBy());
        vars.add(getLimit());
        vars.add(getOffset());
        vars.add(getForUpdate());
        return String.format(template, vars);
    }

    /**
     * Set the where clause. Any substitution tokens `{0}, {1}` will be replaced by value(s) from substitution
     *
     * @param whereClause The where clause of your pfmSelector
     * @param substitution Either a single value (null, Id, String, Decimal, Boolean, DateTime, Date) or a List<Object> containing up to 5 values
     *
     * @return
     */
    public virtual PfmSelector whereClause(String whereClause, Object substitution) {
        this.substitution = substitution;
        this.soqlWhereClause = whereClause;
        return this;
    }

    /**
     * Set an offset to skip rows
     *
     * @param offset
     *
     * @return
     */
    public virtual PfmSelector offset(Integer offset) {
        this.soqlOffset = offset;
        return this;
    }

    /**
     * Set the max returned rows
     *
     * @param limit
     *
     * @return
     */
    public virtual PfmSelector rowLimit(Integer rowLimit) {
        this.soqlLimit = String.valueOf(rowLimit);
        return this;
    }

    /**
     * Set the sort order
     *
     * @param orderBy
     *
     * @return
     */
    public virtual PfmSelector orderBy(String orderBy) {
        this.soqlSort = orderBy;
        return this;
    }

    /**
     * Override the fields that are selected from the related object
     *
     * @param fields
     *
     * @return
     */
    public virtual PfmSelector relatedFields(String fields) {
        if (String.isNotEmpty(fields)) {
            for (String fieldValue : fields.split(',')) {
                this.fieldsToString += ',' + fieldValue;
            }
        }
        return this;
    }
    /**
     * Override to allow a no-argument constructor for an extension
     * to this class
     * @return SObjectType Primary query result SObjectType
     */
    public virtual SObjectType getSObjectType() {
        return type;
    }

    /**
     * Returns a list of Field Sets to use as
     * the query fields if applicable
     * @return List<FieldSet>
     */
    public virtual List<FieldSet> getFieldSets() {
        return fieldSets;
    }

    /**
     * Returns the default list of fields (all that are)
     * accessible for the running user invoking a query
     * where the result is derived from an instance of this
     * class
     * @return List<SObjectField> All accessible field tokens for the
     *                            running user for the SObjectType
     */
    @testVisible
    private List<SObjectField> getFields() {
        return PfmSchemaService.getFieldList(getSObjectType());
    }

    /**
     * @return String config.SOQL_Order_By__c or 'Name ASC' if null
     */
    public virtual String getOrderBy() {
        if (soqlSort != null) {
            return soqlSort;
        } else {
            return 'Name ASC';
        }
    }

    /**
     * @return String Limit sub-statement for SOQL query in string form
     */
    @testVisible
    private String getLimit() {
        if (soqlLimit != null) {
            return soqlLimit;
        } else {
            return '10000';
        }
    }

    /**
     * @return String Limit sub-statement for SOQL query in string form
     */
    @testVisible
    private String getOffset() {
        return soqlOffset == null ? '' : 'OFFSET ' + String.valueOf(soqlOffset);
    }

    @testVisible
    private String getWhereClause(String referenceFieldName) {
        if (referenceFieldName != null) {
            return buildWhereClause(referenceFieldName);
        } else if (soqlWhereClause != null && soqlWhereClause != '') {
            return 'WHERE ' + soqlWhereClause;
        } else {
            return '';
        }
    }

    @testVisible
    private String getForUpdate() {
        if (this.enableForUpdateClause) {
            return 'FOR UPDATE';
        } else {
            return '';
        }
    }

    @testVisible
    private String buildRelationshipQuery() {
        if (lookupFieldDescribe == null)
            return '';
        if (lookupFieldDescribe.getReferenceTo()[0] != getSObjectType()) {
            throw new pfmSelectorException();
        }
        SObjectField token = lookupFieldDescribe.getSObjectField();
        ChildRelationship queryRelationship = null;
        for (ChildRelationship cr : PfmSchemaService.getChildren(getSObjectType())) {
            if (cr.getField() == token) {
                queryRelationship = cr;
                break;
            }
        }
        if (queryRelationship == null) {
            throw new pfmSelectorException();
        }

        SObjectType childSObject = queryRelationship.getChildSObject();
        String childSObjectName = PfmSchemaService.getDescribe(childSObject).getName();
        String relationshipName = queryRelationship.getRelationshipName();
        List<SObjectField> childFields = PfmSchemaService.getFieldList(childSObject);
        String childFieldsToString = new FieldListBuilder(childFields).getStringValue();
        String childOrder = 'Name ASC';
        String childLimit = '2000';
        String template = ',(SELECT {0} FROM {1} ORDER BY {2} LIMIT {3})';
        List<String> vars = new List<String>();
        vars.add(childFieldsToString);
        vars.add(relationshipName);
        vars.add(childOrder);
        vars.add(childLimit);
        return String.format(template, vars);
    }

    /**
     * Returns the string value of the filter portion of a query
     * using the argument of the string value of the reference field
     * to search for matching records
     * @param referenceFieldName The name of the reference field
     *                           to search for matching ids in
     * @return String
     */
    @testVisible
    private String buildWhereClause(String referenceFieldName) {
        return 'WHERE ' +
            referenceFieldName +
            ' IN : idSet' +
            (soqlWhereClause != null && soqlWhereClause != '' ? ' AND ' + soqlWhereClause : '');
    }

    /**
     * Override the fields that are selected from the object
     *
     * @param fields
     *
     * @return
     */
    public virtual PfmSelector fields(String fields) {
        if (String.isNotEmpty(fields)) {
            Integer loopCounter = 0;
            for (String fieldValue : fields.split(',')) {
                // normalize spaces
                fieldValue = fieldValue.trim();
                // fields on related objects aren't in `selectFieldsLower`; let them in too
                if (
                    fieldValue.indexOf('.') != -1 ||
                    this.listBuilder.selectFieldsLowerCase.contains(fieldValue.toLowerCase())
                ) {
                    if (loopCounter == 0) {
                        this.fieldsToString = fieldValue;
                    } else {
                        this.fieldsToString += ',' + fieldValue;
                    }
                    loopCounter++;
                }
            }
        }
        return this;
    }

    public PfmSelector(SObjectType type) {
        this.type = type;
        this.fieldSets = new List<FieldSet>();
        init();
    }

    private void init() {
        if (this.type == null) {
            throw new pfmSelectorException('Type not found');
        }
        sobjectName = this.type.getDescribe().getName();
        fieldsToString = getFieldListBuilder().getStringValue();
        hasFilter = !String.isEmpty(soqlFilter);
    }

    /**
     * Returns a new FieldListBuilder object to use as a factory
     * for creating field lists (comma-separated) in string form
     * @return FieldListBuilder
     */
    @testVisible
    private FieldListBuilder getFieldListBuilder() {
        if (listBuilder == null) {
            if (getFieldSets().size() > 0) {
                listBuilder = new FieldListBuilder(new List<SobjectField>(), getFieldSets());
            } else {
                listBuilder = new FieldListBuilder(getFields(), getFieldSets());
            }
        }
        return listBuilder;
    }

    /**
     * Provides stubs for building lists of fields to use for
     * SOQL queries executed by pfmSelector
     */
    public virtual class StringBuilder {
        protected String stringValue;
        public StringBuilder() {
            /* constructor */
        }
        public StringBuilder(List<String> values) {
            add(values);
        }
        public virtual void add(List<String> values) {
            for (String value : values) {
                add(value);
            }
        }
        public virtual void add(String value) {
            stringValue = (stringValue == null ? value : stringValue + value);
        }
        public virtual String getStringValue() {
            return stringValue;
        }
    }

    /**
     * Extends StringBuilder to provide a utility to produce
     * a comma delimited list in a single string
     * from a list of strings
     * @type CommaDelimitedListBuilder
     */
    public virtual class CommaDelimitedListBuilder extends StringBuilder {
        public CommaDelimitedListBuilder() {
            /* constructor */
        }
        public CommaDelimitedListBuilder(List<String> values) {
            super(values);
        }
        public virtual override void add(String value) {
            stringValue = (stringValue == null ? '{0}' + value : stringValue + ',{0}' + value);
        }
        public override String getStringValue() {
            return getStringValue('');
        }
        public String getStringValue(String itemPrefix) {
            if (stringValue == null) {
                return null;
            }
            return String.format(stringValue, new List<String>{ itemPrefix });
        }
    }

    /**
     * Extends CommadDelimitedListBuilder to provide a factory for creating
     * a comma delimited list of fields in a single string for the pfmSelector
     * class to use to build soql queries
     * @type FieldListBuilder
     */
    public virtual class FieldListBuilder extends CommaDelimitedListBuilder {
        private Set<String> selectFieldsLowerCase = new Set<String>();
        private Map<String, Boolean> inaccessibleFields = new Map<String, Boolean>{
            'connectionsentid' => true,
            'connectionreceivedid' => true
        };

        public FieldListBuilder(List<SObjectField> values) {
            this(values, null);
        }
        public FieldListBuilder(List<SObjectField> values, List<Fieldset> fieldSets) {
            Set<String> selectFields = new Set<String>();
            for (Schema.SObjectField value : values) {
                String fieldName = value.getDescribe().getName();
                if (!fieldName.endsWith('__pc') && !inaccessibleFields.containsKey(fieldName.toLowerCase())) {
                    selectFields.add(fieldName);
                    selectFieldsLowerCase.add(fieldName.toLowerCase());
                }
            }
            if (fieldSets != null) {
                for (Schema.Fieldset fieldSet : fieldSets) {
                    for (Schema.FieldSetMember fieldSetMember : fieldSet.getFields()) {
                        selectFields.add(fieldSetMember.getFieldPath());
                        selectFieldsLowerCase.add(fieldSetMember.getFieldPath().toLowerCase());
                    }
                }
            }
            add(new List<String>(selectFields));
        }
    }
}
