#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""Helper functions shared by the visual classes
"""

# Part of the PsychoPy library
# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2021 Open Science Tools Ltd.
# Distributed under the terms of the GNU General Public License (GPL).

from __future__ import absolute_import, division, print_function

from past.builtins import basestring
from builtins import range
import os
import copy
from pkg_resources import parse_version

from psychopy import logging, colors

# tools must only be imported *after* event or MovieStim breaks on win32
# (JWP has no idea why!)
from psychopy.tools.arraytools import val2array
from psychopy.tools.attributetools import setAttribute
from psychopy.tools.filetools import pathToString

import numpy as np


reportNImageResizes = 5  # stop raising warning after this
# global _nImageResizes
_nImageResizes = 0

try:
    import matplotlib
    if parse_version(matplotlib.__version__) > parse_version('1.2'):
        from matplotlib.path import Path as mplPath
    else:
        from matplotlib import nxutils
    haveMatplotlib = True
except Exception:
    haveMatplotlib = False


def pointInPolygon(x, y, poly):
    """Determine if a point is inside a polygon; returns True if inside.

    (`x`, `y`) is the point to test. `poly` is a list of 3 or more vertices
    as (x,y) pairs. If given an object, such as a `ShapeStim`, will try to
    use its vertices and position as the polygon.

    Same as the `.contains()` method elsewhere.
    """
    try:  # do this using try:...except rather than hasattr() for speed
        poly = poly.verticesPix  # we want to access this only once
    except Exception:
        pass
    nVert = len(poly)
    if nVert < 3:
        msg = 'pointInPolygon expects a polygon with 3 or more vertices'
        logging.warning(msg)
        return False

    # faster if have matplotlib tools:
    if haveMatplotlib:
        if parse_version(matplotlib.__version__) > parse_version('1.2'):
            return mplPath(poly).contains_point([x, y])
        else:
            try:
                return bool(nxutils.pnpoly(x, y, poly))
            except Exception:
                pass

    # fall through to pure python:
    # adapted from http://local.wasp.uwa.edu.au/~pbourke/geometry/insidepoly/
    # via http://www.ariel.com.au/a/python-point-int-poly.html

    inside = False
    # trace (horizontal?) rays, flip inside status if cross an edge:
    p1x, p1y = poly[-1]
    for p2x, p2y in poly:
        if y > min(p1y, p2y) and y <= max(p1y, p2y) and x <= max(p1x, p2x):
            if p1y != p2y:
                xints = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
            if p1x == p2x or x <= xints:
                inside = not inside
        p1x, p1y = p2x, p2y
    return inside


def polygonsOverlap(poly1, poly2):
    """Determine if two polygons intersect; can fail for very pointy polygons.

    Accepts two polygons, as lists of vertices (x,y) pairs. If given an object
    with with (vertices + pos), will try to use that as the polygon.

    Checks if any vertex of one polygon is inside the other polygon. Same as
    the `.overlaps()` method elsewhere.

    :Notes:

    We implement special handling for the `Line` stimulus as it is not a
    proper polygon.
    We do not check for class instances because this would require importing of
    `visual.Line`, creating a circular import. Instead, we assume that a
    "polygon" with only two vertices is meant to specify a line. Pixels between
    the endpoints get interpolated before testing for overlap.

    """
    try:  # do this using try:...except rather than hasattr() for speed
        if poly1.verticesPix.shape == (2, 2):  # Line
            # Interpolate pixels.
            x = np.arange(poly1.verticesPix[0, 0],
                          poly1.verticesPix[1, 0] + 1)
            y = np.arange(poly1.verticesPix[0, 1],
                          poly1.verticesPix[1, 1] + 1)
            poly1_vert_pix = np.column_stack((x,y))
        else:
            poly1_vert_pix = poly1.verticesPix
    except AttributeError:
        poly1_vert_pix = poly1

    try:  # do this using try:...except rather than hasattr() for speed
        if poly2.verticesPix.shape == (2, 2):  # Line
            # Interpolate pixels.
            x = np.arange(poly2.verticesPix[0, 0],
                          poly2.verticesPix[1, 0] + 1)
            y = np.arange(poly2.verticesPix[0, 1],
                          poly2.verticesPix[1, 1] + 1)
            poly2_vert_pix = np.column_stack((x,y))
        else:
            poly2_vert_pix = poly2.verticesPix
    except AttributeError:
        poly2_vert_pix = poly2

    # faster if have matplotlib tools:
    if haveMatplotlib:
        if parse_version(matplotlib.__version__) > parse_version('1.2'):
            if any(mplPath(poly1_vert_pix).contains_points(poly2_vert_pix)):
                return True
            return any(mplPath(poly2_vert_pix).contains_points(poly1_vert_pix))
        else:
            try:  # deprecated in matplotlib 1.2
                if any(nxutils.points_inside_poly(poly1_vert_pix,
                                                  poly2_vert_pix)):
                    return True
                return any(nxutils.points_inside_poly(poly2_vert_pix,
                                                      poly1_vert_pix))
            except Exception:
                pass

    # fall through to pure python:
    for p1 in poly1_vert_pix:
        if pointInPolygon(p1[0], p1[1], poly2_vert_pix):
            return True
    for p2 in poly2_vert_pix:
        if pointInPolygon(p2[0], p2[1], poly1_vert_pix):
            return True
    return False


def setTexIfNoShaders(obj):
    """Useful decorator for classes that need to update Texture after
    other properties. This doesn't actually perform the update, but sets
    a flag so the update occurs at draw time (in case multiple changes all
    need updates only do it once).
    """
    if hasattr(obj, 'useShaders') and not obj.useShaders:
        # we aren't using shaders
        if hasattr(obj, '_needTextureUpdate'):
            obj._needTextureUpdate = True


def setColor(obj, color, colorSpace=None, operation='',
             rgbAttrib='rgb',  # or 'fillRGB' etc
             colorAttrib='color',  # or 'fillColor' etc
             colorSpaceAttrib=None,  # e.g. 'colorSpace' or 'fillColorSpace'
             log=True):
    """Provides the workings needed by setColor, and can perform this for
    any arbitrary color type (e.g. fillColor,lineColor etc).

    OBS: log argument is deprecated - has no effect now.
    Logging should be done when setColor() is called.
    """

    # how this works:
    # rather than using obj.rgb=rgb this function uses setattr(obj,'rgb',rgb)
    # color represents the color in the native space
    # colorAttrib is the name that color will be assigned using
    #   setattr(obj,colorAttrib,color)
    # rgb is calculated from converting color
    # rgbAttrib is the attribute name that rgb is stored under,
    #   e.g. lineRGB for obj.lineRGB
    # colorSpace and takes name from colorAttrib+space e.g.
    # obj.lineRGBSpace=colorSpace

    if colorSpaceAttrib is None:
        colorSpaceAttrib = colorAttrib + 'Space'

    # Handle strings and returns immediately as operations, colorspace etc.
    # does not apply here.
    if isinstance(color, basestring):
        if operation not in ('', None):
            raise TypeError('Cannot do operations on named or hex color')
        if color.lower() in colors.colors255:
            # set rgb, color and colorSpace
            setattr(obj, rgbAttrib,
                    np.array(colors.colors255[color.lower()], float))
            obj.__dict__[colorSpaceAttrib] = 'named'  # e.g. 3rSpace='named'
            obj.__dict__[colorAttrib] = color  # e.g. obj.color='red'
            setTexIfNoShaders(obj)
            return
        elif color[0] == '#' or color[0:2] == '0x':
            # e.g. obj.rgb=[0,0,0]
            setattr(obj, rgbAttrib, np.array(colors.hex2rgb255(color)))
            obj.__dict__[colorSpaceAttrib] = 'hex'  # eg obj.colorSpace='hex'
            obj.__dict__[colorAttrib] = color  # eg Qr='#000000'
            setTexIfNoShaders(obj)
            return
        else:
            # we got a string, but it isn't in the list of named colors and
            # doesn't work as a hex
            raise AttributeError(
                "PsychoPy can't interpret the color string '%s'" % color)

    else:
        # If it wasn't a string, do check and conversion of scalars,
        # sequences and other stuff.
        color = val2array(color, length=3)  # enforces length 1 or 3

        if color is None:
            setattr(obj, rgbAttrib, None)  # e.g. obj.rgb=[0,0,0]
            obj.__dict__[colorSpaceAttrib] = None  # e.g. obj.colorSpace='hex'
            obj.__dict__[colorAttrib] = None  # e.g. obj.color='#000000'
            setTexIfNoShaders(obj)

    # at this point we have a numpy array of 3 vals
    # check if colorSpace is given and use obj.colorSpace if not
    if colorSpace is None:
        colorSpace = getattr(obj, colorSpaceAttrib)
        # using previous color space - if we got this far in the
        # _stColor function then we haven't been given a color name -
        # we don't know what color space to use.
        if colorSpace in ('named', 'hex'):
            logging.error("If you setColor with a numeric color value then"
                          " you need to specify a color space, e.g. "
                          "setColor([1,1,-1],'rgb'), unless you used a "
                          "numeric value previously in which case PsychoPy "
                          "will reuse that color space.)")
            return
    # check whether combining sensible colorSpaces (e.g. can't add things to
    # hex or named colors)
    if operation != '' and getattr(obj, colorSpaceAttrib) in ['named', 'hex']:
        msg = ("setColor() cannot combine ('%s') colors "
               "within 'named' or 'hex' color spaces")
        raise AttributeError(msg % operation)
    elif operation != '' and colorSpace != getattr(obj, colorSpaceAttrib):
        msg = ("setColor cannot combine ('%s') colors"
               " from different colorSpaces (%s,%s)")
        raise AttributeError(msg % (operation, obj.colorSpace, colorSpace))
    else:  # OK to update current color
        if colorSpace == 'named':
            # operations don't make sense for named
            obj.__dict__[colorAttrib] = color
        else:
            setAttribute(obj, colorAttrib, color, log=False,
                         operation=operation, stealth=True)
    # get window (for color conversions)
    if colorSpace in ['dkl', 'lms']:  # only needed for these spaces
        if hasattr(obj, 'dkl_rgb'):
            win = obj  # obj is probably a Window
        elif hasattr(obj, 'win'):
            win = obj.win  # obj is probably a Stimulus
        else:
            win = None
            logging.error("_setColor() is being applied to something"
                          " that has no known Window object")
    # convert new obj.color to rgb space
    newColor = getattr(obj, colorAttrib)
    if colorSpace in ['rgb', 'rgb255', 'named']:
        setattr(obj, rgbAttrib, newColor)
    elif colorSpace == 'dkl':
        if (win.dkl_rgb is None or
                np.all(win.dkl_rgb == np.ones([3, 3]))):
            dkl_rgb = None
        else:
            dkl_rgb = win.dkl_rgb
        setattr(obj, rgbAttrib, colors.dkl2rgb(
            np.asarray(newColor).transpose(), dkl_rgb))
    elif colorSpace == 'lms':
        if (win.lms_rgb is None or
                np.all(win.lms_rgb == np.ones([3, 3]))):
            lms_rgb = None
        elif win.monitor.getPsychopyVersion() < '1.76.00':
            logging.error("The LMS calibration for this monitor was carried"
                          " out before version 1.76.00."
                          " We would STRONGLY recommend that you repeat the "
                          "color calibration before using this color space "
                          "(contact Jon for further info).")
            lms_rgb = win.lms_rgb
        else:
            lms_rgb = win.lms_rgb
        setattr(obj, rgbAttrib, colors.lms2rgb(newColor, lms_rgb))
    elif colorSpace == 'hsv':
        setattr(obj, rgbAttrib, colors.hsv2rgb(np.asarray(newColor)))
    elif colorSpace is None:
        pass  # probably using named colors?
    else:
        logging.error('Unknown colorSpace: %s' % colorSpace)
    # store name of colorSpace for future ref and for drawing
    obj.__dict__[colorSpaceAttrib] = colorSpace
    # if needed, set the texture too
    setTexIfNoShaders(obj)


# set for groupFlipVert:
immutables = {int, float, str, tuple, int, bool,
              np.float64, np.float, np.int, np.long}


def findImageFile(filename):
    """Tests whether the filename is an image file. If not will try some common
    alternatives (e.g. extensions .jpg .tif...)
    """
    # if user supplied correct path then reutnr quickly
    filename = pathToString(filename)
    isfile = os.path.isfile
    if isfile(filename):
        return filename
    orig = copy.copy(filename)

    # search for file using additional extensions

    extensions = ('.jpg', '.png', '.tif', '.bmp', '.gif', '.jpeg', '.tiff')
    # not supported: 'svg', 'eps'
    def logCorrected(orig, actual):
        logging.warn("Requested image {!r} not found but similar filename "
                    "{!r} exists. This will be used instead but changing the "
                    "filename is advised.".format(orig, actual))

    # it already has one but maybe it's wrong? Remove it
    if filename.endswith(extensions):
        filename = os.path.splitext(orig)[0]
    if isfile(filename):
        # had an extension but didn't need one (mac?)
        logCorrected(orig, filename)
        return filename

    # try adding the standard set of extensions
    for ext in extensions:
        if isfile(filename+ext):
            filename += ext
            logCorrected(orig, filename)
            return filename

def groupFlipVert(flipList, yReflect=0):
    """Reverses the vertical mirroring of all items in list ``flipList``.

    Reverses the .flipVert status, vertical (y) positions, and angular
    rotation (.ori). Flipping preserves the relations among the group's
    visual elements. The parameter ``yReflect`` is the y-value of an
    imaginary horizontal line around which to reflect the items;
    default = 0 (screen center).

    Typical usage is to call once prior to any display; call again to un-flip.
    Can be called with a list of all stim to be presented in a given routine.

    Will flip a) all psychopy.visual.xyzStim that have a setFlipVert method,
    b) the y values of .vertices, and c) items in n x 2 lists that are mutable
    (i.e., list, np.array, no tuples): [[x1, y1], [x2, y2], ...]
    """

    if type(flipList) != list:
        flipList = [flipList]
    for item in flipList:
        if type(item) in (list, np.ndarray):
            if type(item[0]) in (list, np.ndarray) and len(item[0]) == 2:
                for i in range(len(item)):
                    item[i][1] = 2 * yReflect - item[i][1]
            else:
                msg = 'Cannot vert-flip elements in "{}", type={}'
                raise ValueError(msg.format(item, type(item[0])))
        elif type(item) in immutables:
            raise ValueError('Cannot change immutable item "{}"'.format(item))
        if hasattr(item, 'setPos'):
            item.setPos([1, -1], '*')
            item.setPos([0, 2 * yReflect], '+')
        elif hasattr(item, 'pos'):  # only if no setPos available
            item.pos[1] *= -1
            item.pos[1] += 2 * yReflect
        if hasattr(item, 'setFlipVert'):  # eg TextStim, custom marker
            item.setFlipVert(not item.flipVert)
        elif hasattr(item, 'vertices'):  # and lacks a setFlipVert method
            try:
                v = item.vertices * [1, -1]  # np.array
            except Exception:
                v = [[item.vertices[i][0], -1 * item.vertices[i][1]]
                     for i in range(len(item.vertices))]
            item.setVertices(v)
        if hasattr(item, 'setOri') and item.ori:
            # non-zero orientation angle
            item.setOri(-1, '*')
            item._needVertexUpdate = True
