# dptcql.py
# Copyright 2016 Roger Marsh
# Licence: See LICENCE (BSD licence)

"""Evaluate a ChessQL query.

Methods defined on the DPT database interfaces are used exclusively.

The ChessQueryLanguageDS class represents a subset of games which match a Chess
Query Language query.

"""
from dptdb import dptapi

from solentware_base.api.where_dpt import Where
from solentware_base.api.find_dpt import Find

from solentware_grid.dpt.dptdatasource import DPTDataSource

from chessql.core import constants

from pgn_read.core.constants import MAP_PGN_SQUARE_NAME_TO_FEN_ORDER

from ..core.filespec import (
    PARTIAL_FILE_DEF,
    NEWGAMES_FIELD_DEF,
    NEWGAMES_FIELD_VALUE,
    PARTIALPOSITION_FIELD_DEF,
    PIECESQUAREMOVE_FIELD_DEF,
    )
from . import dptrayfilter

from ..basecore.cqlgamescommon import (
    ChessQLGamesError,
    ChessQLGamesCommon,
    where_eq_piece_designator,
    AND_FILTERS,
    OR_FILTERS,
    move_number_in_key_range,
    )


class ChessQueryLanguageDS(DPTDataSource, ChessQLGamesCommon):
    
    """Represent subset of games that match a Chess Query Language query."""

    def __init__(self, *a, **k):
        super().__init__(*a, **k)

        # recordclass argument must be used to support non-index field finds.
        self.cqlfinder = Find(self.dbhome, self.dbset)

        self.not_implemented = set()

    def forget_cql_statement_games(
        self, sourceobject, commit=True):
        """Forget game records matching ChessQL statement.

        sourceobject is previously calculated answer.  Set to None to force
        recalculation from query (after editing query statement usually).
        
        """
        gamedb = self.dbhome.get_database(self.dbset, self.dbname)

        # This test copes with databases unavailable while Import in progress.
        # Proper solution awaits implementation of general solentware_grid
        # support.
        if gamedb is None:
            return

        # Forget the list of games under the query key.
        if sourceobject is not None:
            field = self.dbhome.get_dbsets()[
                self.dbset]._secondary[PARTIALPOSITION_FIELD_DEF]
            value = dptapi.APIFieldValue(
                self.dbhome.encode_record_number(sourceobject.key.recno))
            games = gamedb.FindRecords(
                dptapi.APIFindSpecification(
                    field,
                    dptapi.FD_EQ,
                    value),
                dptapi.FD_LOCK_EXCL)
            rsc = games.OpenCursor()
            rsc.GotoFirst()
            while rsc.Accessible():
                r = rsc.AccessCurrentRecordForReadWrite()
                r.DeleteFieldByValue(field, value)
                rsc.Advance(1)
            games.CloseCursor(rsc)
            gamedb.DestroyRecordSet(games)

            # Remove query from set that needs recalculating.
            partialdb = self.dbhome.get_database(
                PARTIAL_FILE_DEF, PARTIAL_FILE_DEF)
            ppview = partialdb.FindRecords(
                dptapi.APIFindSpecification(
                    dptapi.FD_SINGLEREC,
                    sourceobject.key.recno),
                dptapi.FD_LOCK_SHR)
            rsc = ppview.OpenCursor()
            rsc.GotoFirst()
            while rsc.Accessible():
                r = rsc.AccessCurrentRecordForReadWrite()
                r.DeleteFieldByValue(
                    self.dbhome.get_dbsets()[
                        PARTIAL_FILE_DEF]._secondary[NEWGAMES_FIELD_DEF],
                    dptapi.APIFieldValue(
                        self.dbhome.encode_record_selector(
                            NEWGAMES_FIELD_VALUE)))
                rsc.Advance(1)
            ppview.CloseCursor(rsc)
            if commit:
                self.dbhome.commit()
            partialdb.DestroyRecordSet(ppview)

    def calculate_cql_statement_games(
        self, query, sourceobject, initial_recordset=None, commit=True):
        """Find game records matching ChessQL statement.

        query is detail extracted from query statement.
        sourceobject is previously calculated answer.  Set to None to force
        recalculation from query (after editing query statement usually).
        initial_recordset is games to which query is applied.
        
        """
        gamedb = self.dbhome.get_database(self.dbset, self.dbname)

        # This test copes with databases unavailable while Import in progress.
        # Proper solution awaits implementation of general solentware_grid
        # support.
        if gamedb is None:
            return

        games = gamedb.CreateRecordList()

        # Evaluate query.
        initial_recordlist = gamedb.CreateRecordList()
        if initial_recordset is None:
            initial = gamedb.FindRecords()
            initial_recordlist.Place(initial)
            gamedb.DestroyRecordSet(initial)
            del initial
        else:
            initial_recordlist.Place(initial_recordset)
        e = self.get_games_matching_parameters(query, initial_recordlist)
        f = self.get_games_matching_filters(query, e)
        games.Place(f)
        gamedb.DestroyRecordSet(e)
        gamedb.DestroyRecordSet(f)

        # File the list of games under the query key if a list is already filed.
        if sourceobject is not None:
            gamedb.FileRecordsUnder(
                games,
                self.dbhome.get_dbsets()[
                    self.dbset]._secondary[PARTIALPOSITION_FIELD_DEF],
                dptapi.APIFieldValue(
                    self.dbhome.encode_record_number(sourceobject.key.recno)))

            # Remove query from set that needs recalculating.
            partialdb = self.dbhome.get_database(
                PARTIAL_FILE_DEF, PARTIAL_FILE_DEF)
            ppview = partialdb.FindRecords(
                dptapi.APIFindSpecification(
                    dptapi.FD_SINGLEREC,
                    sourceobject.key.recno),
                dptapi.FD_LOCK_SHR)
            rsc = ppview.OpenCursor()
            rsc.GotoFirst()
            while rsc.Accessible():
                r = rsc.AccessCurrentRecordForReadWrite()
                r.DeleteFieldByValue(
                    self.dbhome.get_dbsets()[
                        PARTIAL_FILE_DEF]._secondary[NEWGAMES_FIELD_DEF],
                    dptapi.APIFieldValue(
                        self.dbhome.encode_record_selector(
                            NEWGAMES_FIELD_VALUE)))
                rsc.Advance(1)
            ppview.CloseCursor(rsc)
            if commit:
                self.dbhome.commit()
            partialdb.DestroyRecordSet(ppview)
        gamedb.DestroyRecordSet(initial_recordlist)

        # Hand the list of games over to the user interface.
        self.set_recordset(games)

    def get_cql_statement_games(
        self, query, sourceobject, initial_recordset=None, commit=True):
        """Find game records matching ChessQL statement.

        query is detail extracted from query statement.
        sourceobject is previously calculated answer.  Set to None to force
        recalculation from query (after editing query statement usually).
        initial_recordset is games to which query is applied.
        
        """
        gamedb = self.dbhome.get_database(self.dbset, self.dbname)

        # This test copes with databases unavailable while Import in progress.
        # Proper solution awaits implementation of general solentware_grid
        # support.
        if gamedb is None:
            return

        games = gamedb.CreateRecordList()
        if query is None:
            self.set_recordset(games)
            return

        # Use the previously calculated record set if possible.
        # sourceobject is set to None if query must be recalculated.
        if sourceobject is not None:
            partialdb = self.dbhome.get_database(
                PARTIAL_FILE_DEF, PARTIAL_FILE_DEF)
            ppview = partialdb.FindRecords(
                dptapi.APIFindSpecification(
                    dptapi.FD_SINGLEREC,
                    sourceobject.key.recno),
                dptapi.FD_LOCK_SHR)
            pprecalc = partialdb.FindRecords(
                dptapi.APIFindSpecification(
                    self.dbhome.get_dbsets()[
                        PARTIAL_FILE_DEF]._secondary[NEWGAMES_FIELD_DEF],
                    dptapi.FD_EQ,
                    dptapi.APIFieldValue(
                        self.dbhome.encode_record_selector(
                            NEWGAMES_FIELD_VALUE))),
                ppview)
            if pprecalc.Count() == 0:
                ppcalc = gamedb.FindValues(
                    dptapi.APIFindValuesSpecification(
                        self.dbhome.get_dbsets()[
                            self.dbset]._secondary[PARTIALPOSITION_FIELD_DEF],
                        dptapi.FD_LIKE,
                        dptapi.APIFieldValue(
                            self.dbhome.encode_record_number(
                                sourceobject.key.recno))))
                if ppcalc.Count() != 0:
                    calc = gamedb.FindRecords(
                        dptapi.APIFindSpecification(
                            self.dbhome.get_dbsets()[
                                self.dbset]._secondary[
                                    PARTIALPOSITION_FIELD_DEF],
                            dptapi.FD_EQ,
                            dptapi.APIFieldValue(
                                self.dbhome.encode_record_number(
                                    sourceobject.key.recno))),
                        dptapi.FD_LOCK_SHR)
                    games.Place(calc)
                    self.set_recordset(games)
                    gamedb.DestroyRecordSet(calc)
                    gamedb.DestroyValueSet(ppcalc)
                    partialdb.DestroyRecordSet(ppview)
                    partialdb.DestroyRecordSet(pprecalc)
                    return
                gamedb.DestroyValueSet(ppcalc)
            partialdb.DestroyRecordSet(pprecalc)

        # Evaluate query.
        initial_recordlist = gamedb.CreateRecordList()
        if initial_recordset is None:
            initial = gamedb.FindRecords()
            initial_recordlist.Place(initial)
            gamedb.DestroyRecordSet(initial)
            del initial
        else:
            initial_recordlist.Place(initial_recordset)
        e = self.get_games_matching_parameters(query, initial_recordlist)
        f = self.get_games_matching_filters(query, e)
        games.Place(f)
        gamedb.DestroyRecordSet(e)
        gamedb.DestroyRecordSet(f)

        # File the list of games under the query key if a list is already filed.
        if sourceobject is not None:
            gamedb.FileRecordsUnder(
                games,
                self.dbhome.get_dbsets()[
                    self.dbset]._secondary[PARTIALPOSITION_FIELD_DEF],
                dptapi.APIFieldValue(
                    self.dbhome.encode_record_number(sourceobject.key.recno)))

            # Remove query from set that needs recalculating.
            rsc = ppview.OpenCursor()
            rsc.GotoFirst()
            while rsc.Accessible():
                r = rsc.AccessCurrentRecordForReadWrite()
                r.DeleteFieldByValue(
                    self.dbhome.get_dbsets()[
                        PARTIAL_FILE_DEF]._secondary[NEWGAMES_FIELD_DEF],
                    dptapi.APIFieldValue(
                        self.dbhome.encode_record_selector(
                            NEWGAMES_FIELD_VALUE)))
                rsc.Advance(1)
            ppview.CloseCursor(rsc)
            if commit:
                self.dbhome.commit()
            partialdb.DestroyRecordSet(ppview)
        gamedb.DestroyRecordSet(initial_recordlist)

        # Hand the list of games over to the user interface.
        self.set_recordset(games)

    def get_games_matching_filters(self, query, games):
        """Select the games which meet the ChessQL cql() ... filters.

        Walk node tree for every movenumber and combine evaluated game sets.

        """
        found = self.cqlfinder.context.CreateRecordList()
        if not query.cql_filters:
            return found
        query.cql_filters.expand_child_piece_designators()
        cursor = self.cqlfinder.context.OpenDirectValueCursor(
            dptapi.APIFindValuesSpecification(
                self.dbhome.get_dbsets()[
                    self.dbset]._secondary[PIECESQUAREMOVE_FIELD_DEF]))
        cursor.SetDirection(dptapi.CURSOR_ASCENDING)
        cursor.GotoLast()
        key = None
        if cursor.Accessible():
            key = cursor.GetCurrentValue().ExtractString()
        self.cqlfinder.context.CloseDirectValueCursor(cursor)
        if not key:
            return found
        for movenumber in move_number_in_key_range(key):
            e = self._games_matching_filter(
                query.cql_filters, games, movenumber, 0)
            found.Place(e)
            self.cqlfinder.context.DestroyRecordSet(e)
        return found

    # Version of get_games_matching_filters() to process CQL parameters.
    def get_games_matching_parameters(self, query, games):
        """Select the games which meet the ChessQL cql(...) parameters

        Walk node tree for cql parameters and combine evaluated game sets.

        """
        found = self.cqlfinder.context.CreateRecordList()
        found.Place(games)
        return found
        
    def _games_matching_filter(
        self, filter_, initialgames, movenumber, variation):
        """Return games matching filters in CQL statement.

        It is assumed FSNode.designator_set contains the expanded piece
        designators.

        """
        if filter_.type in AND_FILTERS:
            games = self.cqlfinder.context.CreateRecordList()
            games.Place(initialgames)
            for n in filter_.children:
                if n.type == constants.PIECE_DESIGNATOR_FILTER:
                    e = self._games_matching_piece_designator(
                        n, games, movenumber, variation)
                    self.recordset_iand(games, e)
                    self.cqlfinder.context.DestroyRecordSet(e)
                    if games.Count() == 0:
                        return games
            for n in filter_.children:
                if self.is_filter_implemented(n):
                    continue
                e = self._games_matching_filter(n, games, movenumber, variation)
                self.recordset_iand(games, e)
                self.cqlfinder.context.DestroyRecordSet(e)
                if games.Count() == 0:
                    return games
            return games
        elif filter_.type in OR_FILTERS:
            games = self.cqlfinder.context.CreateRecordList()
            for n in filter_.children:
                if self.is_filter_implemented(n):
                    continue
                e = self._games_matching_filter(
                    n, initialgames, movenumber, variation)
                games.Place(e)
                self.cqlfinder.context.DestroyRecordSet(e)
            return games
        else:
            if filter_.type == constants.PIECE_DESIGNATOR_FILTER:
                return self._games_matching_piece_designator(
                    filter_, initialgames, movenumber, variation)
            if filter_.type == constants.ON:
                return self._games_matching_on_filter(
                    filter_, initialgames, movenumber, variation)
            if filter_.type == constants.RAY:
                return self._games_matching_ray_filter(
                    filter_, initialgames, movenumber, variation)
            games = self.cqlfinder.context.CreateRecordList()
            games.Place(initialgames)
            return games
        
    def _games_matching_piece_designator(
        self, filter_, datasource, movenumber, variation):
        """Return games matching a piece designator."""

        if filter_.type != constants.PIECE_DESIGNATOR_FILTER:
            raise ChessQLGamesError(''.join(("'",
                                             constants.PIECE_DESIGNATOR_FILTER,
                                             "' expected but '",
                                             str(filter_.type),
                                             "'received",
                                             )))
        answer = self._evaluate_piece_designator(
            movenumber,
            variation,
            filter_.data.designator_set)
        self.recordset_iand(answer, datasource)
        return answer
        
    def _games_matching_on_filter(
        self, filter_, datasource, movenumber, variation):
        """Return games matching an on filter."""

        if filter_.type != constants.ON:
            raise ChessQLGamesError(''.join(("'",
                                             constants.ON,
                                             "' expected but '",
                                             str(filter_.type),
                                             "'received",
                                             )))
        left = self.squares_matching_filter(
            filter_.children[0],
            set(MAP_PGN_SQUARE_NAME_TO_FEN_ORDER))
        right = self.squares_matching_filter(
            filter_.children[1],
            set(MAP_PGN_SQUARE_NAME_TO_FEN_ORDER))
        squares = left.intersection(right)
        if not squares:
            return self.cqlfinder.context.CreateRecordList()
        left = self.pieces_matching_filter(
            filter_.children[0],
            set(constants.ALL_PIECES))
        right = self.pieces_matching_filter(
            filter_.children[1],
            set(constants.ALL_PIECES))
        pieces = left.intersection(right)
        if not pieces:
            return self.cqlfinder.context.CreateRecordList()
        answer = self._evaluate_piece_designator(
            movenumber,
            variation,
            {p + s for p in pieces for s in squares})
        leftgames = self._games_matching_filter(
            filter_.children[0], datasource, movenumber, variation)
        rightgames = self._games_matching_filter(
            filter_.children[1], datasource, movenumber, variation)
        self.recordset_iand(answer, leftgames)
        self.recordset_iand(answer, rightgames)
        self.cqlfinder.context.DestroyRecordSet(leftgames)
        self.cqlfinder.context.DestroyRecordSet(rightgames)
        self.recordset_iand(answer, datasource)
        return answer
        
    def _games_matching_ray_filter(
        self, filter_, datasource, movenumber, variation):
        """Return games matching an on filter."""

        if filter_.type != constants.RAY:
            raise ChessQLGamesError(''.join(("'",
                                             constants.RAY,
                                             "' expected but '",
                                             str(filter_.type),
                                             "'received",
                                             )))
        rf = dptrayfilter.RayFilter(filter_, movenumber, variation)
        rf.prune_end_squares(self.dbhome)
        rf.find_games_for_end_squares(self.cqlfinder)
        rf.find_games_for_middle_squares(self.cqlfinder)

        # So that downstream methods have a valid object to work with.
        recordset = self.cqlfinder.context.CreateRecordList()
        for gs in rf.ray_games.values():
            recordset.Place(gs)
            self.cqlfinder.context.DestroyRecordSet(gs)
        return recordset

    def _evaluate_piece_designator(
        self, move_number, variation_code, piecesquares):
        """.

        The fieldname PieceSquareMove is now unavoidably a bit confusing, where
        move refers to a token in the movetext when the piece is on the square.
        Nothing to do with move number in the movetext, which is the ChessQL
        meaning of move in this context.

        """
        w = Where(where_eq_piece_designator(
            move_number, variation_code, piecesquares))
        w.lex()
        w.parse()

        # What if v != [] afterward.
        # The commented version is wrong because datasource is an APIRecordList
        # object, accessed via a swigged interface, which does not provide
        # equivalents of the dbhome and dbset attributes.
        # Also the validation should be against the object cqlfinder is going
        # to search.
        #v = w.validate(datasource.dbhome, datasource.dbset)
        v = w.validate(self.cqlfinder.db, self.cqlfinder.dbset)

        w.evaluate(self.cqlfinder)
        answer = self.cqlfinder.context.CreateRecordList()
        answer.Place(w.node.result.answer)
        w.close_all_nodes(self.cqlfinder)
        return answer

    # Python '&=' appears enough in ..basecore.cqlgames to justify this method.
    def recordset_iand(self, recordset, other):
        """Modify recordset by '&=' operator: recordset &= other."""
        s = self.cqlfinder.context.CreateRecordList()
        s.Place(recordset)
        s.Remove(other)
        recordset.Remove(s)
        self.cqlfinder.context.DestroyRecordSet(s)
