import copy
import math
import warnings
from os.path import basename as BASENAME

import numpy as np
import scipy.sparse
from scipy.sparse.csgraph import connected_components

# Copyright (c) 2007-2015, PySAL Developers
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
#   list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright
#   notice, this list of conditions and the following disclaimer in the
#   documentation and/or other materials provided with the distribution.
#
# * Neither the name of the GeoDa Center for Geospatial Analysis and Computation
#   nor the names of its contributors may be used to endorse or promote products
#   derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

### copied form copied from pysal package
# see https://github.com/pysal/libpysal/blob/1d6326129904776b0f67597603f3d0af05a9da4c/libpysal/weights/weights.py
# pointing to a commit, not to main for reproducibility
# Removed classmethods from_file, from_shapefile, from_WSP, from_adjlist and method to_adjlist to
# avoid heavy dependencies.

class W(object):
    """
    Spatial weights class. Class attributes are described by their
    docstrings. to view, use the ``help`` function.

    Parameters
    ----------

    neighbors : dict
        Key is region ID, value is a list of neighbor IDS.
        For example, ``{'a':['b'],'b':['a','c'],'c':['b']}``.
    weights : dict
       Key is region ID, value is a list of edge weights.
       If not supplied all edge weights are assumed to have a weight of 1.
       For example, ``{'a':[0.5],'b':[0.5,1.5],'c':[1.5]}``.
    id_order : list
       An ordered list of ids, defines the order of observations when
       iterating over ``W`` if not set, lexicographical ordering is used
       to iterate and the ``id_order_set`` property will return ``False``.
       This can be set after creation by setting the ``id_order`` property.
    silence_warnings : bool
       By default ``libpysal`` will print a warning if the dataset contains
       any disconnected components or islands. To silence this warning set this
       parameter to ``True``.
    ids : list
        Values to use for keys of the neighbors and weights ``dict`` objects.

    Attributes
    ----------

    asymmetries
    cardinalities
    component_labels
    diagW2
    diagWtW
    diagWtW_WW
    histogram
    id2i
    id_order
    id_order_set
    islands
    max_neighbors
    mean_neighbors
    min_neighbors
    n
    n_components
    neighbor_offsets
    nonzero
    pct_nonzero
    s0
    s1
    s2
    s2array
    sd
    sparse
    trcW2
    trcWtW
    trcWtW_WW
    transform

    Examples
    --------

    >>> from libpysal.weights import W
    >>> neighbors = {0: [3, 1], 1: [0, 4, 2], 2: [1, 5], 3: [0, 6, 4], 4: [1, 3, 7, 5], 5: [2, 4, 8], 6: [3, 7], 7: [4, 6, 8], 8: [5, 7]}
    >>> weights = {0: [1, 1], 1: [1, 1, 1], 2: [1, 1], 3: [1, 1, 1], 4: [1, 1, 1, 1], 5: [1, 1, 1], 6: [1, 1], 7: [1, 1, 1], 8: [1, 1]}
    >>> w = W(neighbors, weights)
    >>> "%.3f"%w.pct_nonzero
    '29.630'

    Read from external `.gal file <https://geodacenter.github.io/workbook/4a_contig_weights/lab4a.html#gal-weights-file>`_.

    >>> import libpysal
    >>> w = libpysal.io.open(libpysal.examples.get_path("stl.gal")).read()
    >>> w.n
    78
    >>> "%.3f"%w.pct_nonzero
    '6.542'

    Set weights implicitly.

    >>> neighbors = {0: [3, 1], 1: [0, 4, 2], 2: [1, 5], 3: [0, 6, 4], 4: [1, 3, 7, 5], 5: [2, 4, 8], 6: [3, 7], 7: [4, 6, 8], 8: [5, 7]}
    >>> w = W(neighbors)
    >>> round(w.pct_nonzero,3)
    29.63
    >>> from libpysal.weights import lat2W
    >>> w = lat2W(100, 100)
    >>> w.trcW2
    39600.0
    >>> w.trcWtW
    39600.0
    >>> w.transform='r'
    >>> round(w.trcW2, 3)
    2530.722
    >>> round(w.trcWtW, 3)
    2533.667

    Cardinality Histogram:

    >>> w.histogram
    [(2, 4), (3, 392), (4, 9604)]

    Disconnected observations (islands):

    >>> from libpysal.weights import W
    >>> w = W({1:[0],0:[1],2:[], 3:[]})

    UserWarning: The weights matrix is not fully connected:
    There are 3 disconnected components.
    There are 2 islands with ids: 2, 3.

    """

    def __init__(
            self, neighbors, weights=None, id_order=None, silence_warnings=False, ids=None
    ):
        self.silence_warnings = silence_warnings
        self.transformations = {}
        self.neighbors = neighbors
        if not weights:
            weights = {}
            for key in neighbors:
                weights[key] = [1.0] * len(neighbors[key])
        self.weights = weights
        self.transformations["O"] = self.weights.copy()  # original weights
        self.transform = "O"
        if id_order is None:
            self._id_order = list(self.neighbors.keys())
            self._id_order.sort()
            self._id_order_set = False
        else:
            self._id_order = id_order
            self._id_order_set = True
        self._reset()
        self._n = len(self.weights)
        if (not self.silence_warnings) and (self.n_components > 1):
            message = (
                    "The weights matrix is not fully connected: "
                    "\n There are %d disconnected components." % self.n_components
            )
            ni = len(self.islands)
            if ni == 1:
                message = message + "\n There is 1 island with id: %s." % (
                    str(self.islands[0])
                )
            elif ni > 1:
                message = message + "\n There are %d islands with ids: %s." % (
                    ni,
                    ", ".join(str(island) for island in self.islands),
                )
            warnings.warn(message)

    def _reset(self):
        """Reset properties."""
        self._cache = {}

    def to_networkx(self):
        """Convert a weights object to a ``networkx`` graph.

        Returns
        -------
        A ``networkx`` graph representation of the ``W`` object.
        """
        try:
            import networkx as nx
        except ImportError:
            raise ImportError("NetworkX is required to use this function.")
        G = nx.DiGraph() if len(self.asymmetries) > 0 else nx.Graph()
        return nx.from_scipy_sparse_matrix(self.sparse, create_using=G)

    @classmethod
    def from_networkx(cls, graph, weight_col="weight"):
        """Convert a ``networkx`` graph to a PySAL ``W`` object.

        Parameters
        ----------
        graph : networkx.Graph
            The graph to convert to a ``W``.
        weight_col : string
            If the graph is labeled, this should be the name of the field
            to use as the weight for the ``W``.

        Returns
        -------
        w : libpysal.weights.W
            A ``W`` object containing the same graph as the ``networkx`` graph.
        """
        try:
            import networkx as nx
        except ImportError:
            raise ImportError("NetworkX is required to use this function.")
        sparse_matrix = nx.to_scipy_sparse_matrix(graph)
        w = WSP(sparse_matrix).to_W()
        return w

    @property
    def sparse(self):
        """Sparse matrix object. For any matrix manipulations required for w,
        ``w.sparse`` should be used. This is based on ``scipy.sparse``.
        """
        if "sparse" not in self._cache:
            self._sparse = self._build_sparse()
            self._cache["sparse"] = self._sparse
        return self._sparse

    @property
    def n_components(self):
        """Store whether the adjacency matrix is fully connected."""
        if "n_components" not in self._cache:
            self._n_components, self._component_labels = connected_components(
                self.sparse
            )
            self._cache["n_components"] = self._n_components
            self._cache["component_labels"] = self._component_labels
        return self._n_components

    @property
    def component_labels(self):
        """Store the graph component in which each observation falls."""
        if "component_labels" not in self._cache:
            self._n_components, self._component_labels = connected_components(
                self.sparse
            )
            self._cache["n_components"] = self._n_components
            self._cache["component_labels"] = self._component_labels
        return self._component_labels

    def _build_sparse(self):
        """Construct the sparse attribute."""

        row = []
        col = []
        data = []
        id2i = self.id2i
        for i, neigh_list in list(self.neighbor_offsets.items()):
            card = self.cardinalities[i]
            row.extend([id2i[i]] * card)
            col.extend(neigh_list)
            data.extend(self.weights[i])
        row = np.array(row)
        col = np.array(col)
        data = np.array(data)
        s = scipy.sparse.csr_matrix((data, (row, col)), shape=(self.n, self.n))
        return s

    @property
    def id2i(self):
        """Dictionary where the key is an ID and the value is that ID's
        index in ``W.id_order``.
        """
        if "id2i" not in self._cache:
            self._id2i = {}
            for i, id_i in enumerate(self._id_order):
                self._id2i[id_i] = i
            self._id2i = self._id2i
            self._cache["id2i"] = self._id2i
        return self._id2i

    @property
    def n(self):
        """Number of units."""
        if "n" not in self._cache:
            self._n = len(self.neighbors)
            self._cache["n"] = self._n
        return self._n

    @property
    def s0(self):
        r"""``s0`` is defined as

        .. math::

               s0=\sum_i \sum_j w_{i,j}

        """
        if "s0" not in self._cache:
            self._s0 = self.sparse.sum()
            self._cache["s0"] = self._s0
        return self._s0

    @property
    def s1(self):
        r"""``s1`` is defined as

        .. math::

               s1=1/2 \sum_i \sum_j \Big(w_{i,j} + w_{j,i}\Big)^2

        """
        if "s1" not in self._cache:
            t = self.sparse.transpose()
            t = t + self.sparse
            t2 = t.multiply(t)  # element-wise square
            self._s1 = t2.sum() / 2.0
            self._cache["s1"] = self._s1
        return self._s1

    @property
    def s2array(self):
        """Individual elements comprising ``s2``.

        See Also
        --------
        s2

        """
        if "s2array" not in self._cache:
            s = self.sparse
            self._s2array = np.array(s.sum(1) + s.sum(0).transpose()) ** 2
            self._cache["s2array"] = self._s2array
        return self._s2array

    @property
    def s2(self):
        r"""``s2`` is defined as

        .. math::

                s2=\sum_j \Big(\sum_i w_{i,j} + \sum_i w_{j,i}\Big)^2

        """
        if "s2" not in self._cache:
            self._s2 = self.s2array.sum()
            self._cache["s2"] = self._s2
        return self._s2

    @property
    def trcW2(self):
        """Trace of :math:`WW`.

        See Also
        --------
        diagW2

        """
        if "trcW2" not in self._cache:
            self._trcW2 = self.diagW2.sum()
            self._cache["trcw2"] = self._trcW2
        return self._trcW2

    @property
    def diagW2(self):
        """Diagonal of :math:`WW`.

        See Also
        --------
        trcW2

        """
        if "diagw2" not in self._cache:
            self._diagW2 = (self.sparse * self.sparse).diagonal()
            self._cache["diagW2"] = self._diagW2
        return self._diagW2

    @property
    def diagWtW(self):
        """Diagonal of :math:`W^{'}W`.

        See Also
        --------
        trcWtW

        """
        if "diagWtW" not in self._cache:
            self._diagWtW = (self.sparse.transpose() * self.sparse).diagonal()
            self._cache["diagWtW"] = self._diagWtW
        return self._diagWtW

    @property
    def trcWtW(self):
        """Trace of :math:`W^{'}W`.

        See Also
        --------
        diagWtW

        """
        if "trcWtW" not in self._cache:
            self._trcWtW = self.diagWtW.sum()
            self._cache["trcWtW"] = self._trcWtW
        return self._trcWtW

    @property
    def diagWtW_WW(self):
        """Diagonal of :math:`W^{'}W + WW`."""
        if "diagWtW_WW" not in self._cache:
            wt = self.sparse.transpose()
            w = self.sparse
            self._diagWtW_WW = (wt * w + w * w).diagonal()
            self._cache["diagWtW_WW"] = self._diagWtW_WW
        return self._diagWtW_WW

    @property
    def trcWtW_WW(self):
        """Trace of :math:`W^{'}W + WW`."""
        if "trcWtW_WW" not in self._cache:
            self._trcWtW_WW = self.diagWtW_WW.sum()
            self._cache["trcWtW_WW"] = self._trcWtW_WW
        return self._trcWtW_WW

    @property
    def pct_nonzero(self):
        """Percentage of nonzero weights."""
        if "pct_nonzero" not in self._cache:
            self._pct_nonzero = 100.0 * self.sparse.nnz / (1.0 * self._n ** 2)
            self._cache["pct_nonzero"] = self._pct_nonzero
        return self._pct_nonzero

    @property
    def cardinalities(self):
        """Number of neighbors for each observation."""
        if "cardinalities" not in self._cache:
            c = {}
            for i in self._id_order:
                c[i] = len(self.neighbors[i])
            self._cardinalities = c
            self._cache["cardinalities"] = self._cardinalities
        return self._cardinalities

    @property
    def max_neighbors(self):
        """Largest number of neighbors."""
        if "max_neighbors" not in self._cache:
            self._max_neighbors = max(self.cardinalities.values())
            self._cache["max_neighbors"] = self._max_neighbors
        return self._max_neighbors

    @property
    def mean_neighbors(self):
        """Average number of neighbors."""
        if "mean_neighbors" not in self._cache:
            self._mean_neighbors = np.mean(list(self.cardinalities.values()))
            self._cache["mean_neighbors"] = self._mean_neighbors
        return self._mean_neighbors

    @property
    def min_neighbors(self):
        """Minimum number of neighbors."""
        if "min_neighbors" not in self._cache:
            self._min_neighbors = min(self.cardinalities.values())
            self._cache["min_neighbors"] = self._min_neighbors
        return self._min_neighbors

    @property
    def nonzero(self):
        """Number of nonzero weights."""
        if "nonzero" not in self._cache:
            self._nonzero = self.sparse.nnz
            self._cache["nonzero"] = self._nonzero
        return self._nonzero

    @property
    def sd(self):
        """Standard deviation of number of neighbors."""
        if "sd" not in self._cache:
            self._sd = np.std(list(self.cardinalities.values()))
            self._cache["sd"] = self._sd
        return self._sd

    @property
    def asymmetries(self):
        """List of id pairs with asymmetric weights."""
        if "asymmetries" not in self._cache:
            self._asymmetries = self.asymmetry()
            self._cache["asymmetries"] = self._asymmetries
        return self._asymmetries

    @property
    def islands(self):
        """List of ids without any neighbors."""
        if "islands" not in self._cache:
            self._islands = [i for i, c in list(self.cardinalities.items()) if c == 0]
            self._cache["islands"] = self._islands
        return self._islands

    @property
    def histogram(self):
        """Cardinality histogram as a dictionary where key is the id and
        value is the number of neighbors for that unit.
        """
        if "histogram" not in self._cache:
            ct, bin = np.histogram(
                list(self.cardinalities.values()),
                list(range(self.min_neighbors, self.max_neighbors + 2)),
            )
            self._histogram = list(zip(bin, ct))
            self._cache["histogram"] = self._histogram
        return self._histogram

    def __getitem__(self, key):
        """Allow a dictionary like interaction with the weights class.

        Examples
        --------
        >>> from libpysal.weights import lat2W
        >>> w = lat2W()

        >>> w[0] == dict({1: 1.0, 5: 1.0})
        True
        """
        return dict(list(zip(self.neighbors[key], self.weights[key])))

    def __iter__(self):
        """
        Support iteration over weights.

        Examples
        --------
        >>> from libpysal.weights import lat2W
        >>> w=lat2W(3,3)
        >>> for i,wi in enumerate(w):
        ...     print(i,wi[0])
        ...
        0 0
        1 1
        2 2
        3 3
        4 4
        5 5
        6 6
        7 7
        8 8
        >>>
        """
        for i in self._id_order:
            yield i, dict(list(zip(self.neighbors[i], self.weights[i])))

    def remap_ids(self, new_ids):
        """
        In place modification throughout ``W`` of id values from
        ``w.id_order`` to ``new_ids`` in all.

        Parameters
        ----------

        new_ids : list, numpy.ndarray
            Aligned list of new ids to be inserted. Note that first
            element of ``new_ids`` will replace first element of
            ``w.id_order``, second element of ``new_ids`` replaces second
            element of ``w.id_order`` and so on.

        Examples
        --------

        >>> from libpysal.weights import lat2W
        >>> w = lat2W(3, 3)
        >>> w.id_order
        [0, 1, 2, 3, 4, 5, 6, 7, 8]
        >>> w.neighbors[0]
        [3, 1]
        >>> new_ids = ['id%i'%id for id in w.id_order]
        >>> _ = w.remap_ids(new_ids)
        >>> w.id_order
        ['id0', 'id1', 'id2', 'id3', 'id4', 'id5', 'id6', 'id7', 'id8']
        >>> w.neighbors['id0']
        ['id3', 'id1']
        """

        old_ids = self._id_order
        if len(old_ids) != len(new_ids):
            raise Exception(
                "W.remap_ids: length of `old_ids` does not match             that of"
                " new_ids"
            )
        if len(set(new_ids)) != len(new_ids):
            raise Exception("W.remap_ids: list `new_ids` contains duplicates")
        else:
            new_neighbors = {}
            new_weights = {}
            old_transformations = self.transformations["O"].copy()
            new_transformations = {}
            for o, n in zip(old_ids, new_ids):
                o_neighbors = self.neighbors[o]
                o_weights = self.weights[o]
                n_neighbors = [new_ids[old_ids.index(j)] for j in o_neighbors]
                new_neighbors[n] = n_neighbors
                new_weights[n] = o_weights[:]
                new_transformations[n] = old_transformations[o]
            self.neighbors = new_neighbors
            self.weights = new_weights
            self.transformations["O"] = new_transformations

            id_order = [self._id_order.index(o) for o in old_ids]
            for i, id_ in enumerate(id_order):
                self.id_order[id_] = new_ids[i]

            self._reset()

    def __set_id_order(self, ordered_ids):
        """Set the iteration order in w. ``W`` can be iterated over. On
        construction the iteration order is set to the lexicographic order of
        the keys in the ``w.weights`` dictionary. If a specific order
        is required it can be set with this method.

        Parameters
        ----------

        ordered_ids : sequence
            Identifiers for observations in specified order.

        Notes
        -----

        The ``ordered_ids`` parameter is checked against the ids implied
        by the keys in ``w.weights``. If they are not equivalent sets an
        exception is raised and the iteration order is not changed.

        Examples
        --------

        >>> from libpysal.weights import lat2W
        >>> w=lat2W(3,3)
        >>> for i,wi in enumerate(w):
        ...     print(i, wi[0])
        ...
        0 0
        1 1
        2 2
        3 3
        4 4
        5 5
        6 6
        7 7
        8 8
        >>> w.id_order
        [0, 1, 2, 3, 4, 5, 6, 7, 8]
        >>> w.id_order=range(8,-1,-1)
        >>> list(w.id_order)
        [8, 7, 6, 5, 4, 3, 2, 1, 0]
        >>> for i,w_i in enumerate(w):
        ...     print(i,w_i[0])
        ...
        0 8
        1 7
        2 6
        3 5
        4 4
        5 3
        6 2
        7 1
        8 0

        """

        if set(self._id_order) == set(ordered_ids):
            self._id_order = ordered_ids
            self._id_order_set = True
            self._reset()
        else:
            raise Exception("ordered_ids do not align with W ids")

    def __get_id_order(self):
        """Returns the ids for the observations in the order in which they
        would be encountered if iterating over the weights.
        """
        return self._id_order

    id_order = property(__get_id_order, __set_id_order)

    @property
    def id_order_set(self):
        """Returns ``True`` if user has set ``id_order``, ``False`` if not.

        Examples
        --------
        >>> from libpysal.weights import lat2W
        >>> w=lat2W()
        >>> w.id_order_set
        True
        """
        return self._id_order_set

    @property
    def neighbor_offsets(self):
        """
        Given the current ``id_order``, ``neighbor_offsets[id]`` is the
        offsets of the id's neighbors in ``id_order``.

        Returns
        -------
        neighbor_list : list
            Offsets of the id's neighbors in ``id_order``.

        """

        if "neighbors_0" not in self._cache:
            self.__neighbors_0 = {}
            id2i = self.id2i
            for j, neigh_list in list(self.neighbors.items()):
                self.__neighbors_0[j] = [id2i[neigh] for neigh in neigh_list]
            self._cache["neighbors_0"] = self.__neighbors_0

        neighbor_list = self.__neighbors_0

        return neighbor_list

    def get_transform(self):
        """Getter for transform property.

        Returns
        -------
        transformation : str, None
            Valid transformation value. See the ``transform``
            parameters in ``set_transform()`` for a detailed description.

        See also
        --------
        set_transform

        """

        return self._transform

    def set_transform(self, value="B"):
        """Transformations of weights.

        Parameters
        ----------
        transform : str
            This parameter is not case sensitive. The following are
            valid transformations.

            * **B** -- Binary
            * **R** -- Row-standardization (global sum :math:`=n`)
            * **D** -- Double-standardization (global sum :math:`=1`)
            * **V** -- Variance stabilizing
            * **O** -- Restore original transformation (from instantiation)

        Notes
        -----

        Transformations are applied only to the value of the weights at
        instantiation. Chaining of transformations cannot be done on a ``W``
        instance.


        Examples
        --------
        >>> from libpysal.weights import lat2W
        >>> w=lat2W()
        >>> w.weights[0]
        [1.0, 1.0]
        >>> w.transform
        'O'
        >>> w.transform='r'
        >>> w.weights[0]
        [0.5, 0.5]
        >>> w.transform='b'
        >>> w.weights[0]
        [1.0, 1.0]
        """
        value = value.upper()
        self._transform = value
        if value in self.transformations:
            self.weights = self.transformations[value]
            self._reset()
        else:
            if value == "R":
                # row standardized weights
                weights = {}
                self.weights = self.transformations["O"]
                for i in self.weights:
                    wijs = self.weights[i]
                    row_sum = sum(wijs) * 1.0
                    if row_sum == 0.0:
                        if not self.silence_warnings:
                            print(("WARNING: ", i, " is an island (no neighbors)"))
                    weights[i] = [wij / row_sum for wij in wijs]
                weights = weights
                self.transformations[value] = weights
                self.weights = weights
                self._reset()
            elif value == "D":
                # doubly-standardized weights
                # update current chars before doing global sum
                self._reset()
                s0 = self.s0
                ws = 1.0 / s0
                weights = {}
                self.weights = self.transformations["O"]
                for i in self.weights:
                    wijs = self.weights[i]
                    weights[i] = [wij * ws for wij in wijs]
                weights = weights
                self.transformations[value] = weights
                self.weights = weights
                self._reset()
            elif value == "B":
                # binary transformation
                weights = {}
                self.weights = self.transformations["O"]
                for i in self.weights:
                    wijs = self.weights[i]
                    weights[i] = [1.0 for wij in wijs]
                weights = weights
                self.transformations[value] = weights
                self.weights = weights
                self._reset()
            elif value == "V":
                # variance stabilizing
                weights = {}
                q = {}
                k = self.cardinalities
                s = {}
                Q = 0.0
                self.weights = self.transformations["O"]
                for i in self.weights:
                    wijs = self.weights[i]
                    q[i] = math.sqrt(sum([wij * wij for wij in wijs]))
                    s[i] = [wij / q[i] for wij in wijs]
                    Q += sum([si for si in s[i]])
                nQ = self.n / Q
                for i in self.weights:
                    weights[i] = [w * nQ for w in s[i]]
                weights = weights
                self.transformations[value] = weights
                self.weights = weights
                self._reset()
            elif value == "O":
                # put weights back to original transformation
                weights = {}
                original = self.transformations[value]
                self.weights = original
                self._reset()
            else:
                raise Exception("unsupported weights transformation")

    transform = property(get_transform, set_transform)

    def asymmetry(self, intrinsic=True):
        r"""
        Asymmetry check.

        Parameters
        ----------
        intrinsic : bool
            Default is ``True``. Intrinsic symmetry is defined as

            .. math::

                w_{i,j} == w_{j,i}

            If ``intrinsic`` is ``False`` symmetry is defined as

            .. math::

                i \in N_j \ \& \ j \in N_i

            where :math:`N_j` is the set of neighbors for :math:`j`.

        Returns
        -------
        asymmetries : list
            Empty if no asymmetries are found if asymmetries, then a
            ``list`` of ``(i,j)`` tuples is returned.

        Examples
        --------

        >>> from libpysal.weights import lat2W
        >>> w=lat2W(3,3)
        >>> w.asymmetry()
        []
        >>> w.transform='r'
        >>> w.asymmetry()
        [(0, 1), (0, 3), (1, 0), (1, 2), (1, 4), (2, 1), (2, 5), (3, 0), (3, 4), (3, 6), (4, 1), (4, 3), (4, 5), (4, 7), (5, 2), (5, 4), (5, 8), (6, 3), (6, 7), (7, 4), (7, 6), (7, 8), (8, 5), (8, 7)]
        >>> result = w.asymmetry(intrinsic=False)
        >>> result
        []
        >>> neighbors={0:[1,2,3], 1:[1,2,3], 2:[0,1], 3:[0,1]}
        >>> weights={0:[1,1,1], 1:[1,1,1], 2:[1,1], 3:[1,1]}
        >>> w=W(neighbors,weights)
        >>> w.asymmetry()
        [(0, 1), (1, 0)]
        """

        if intrinsic:
            wd = self.sparse.transpose() - self.sparse
        else:
            transform = self.transform
            self.transform = "b"
            wd = self.sparse.transpose() - self.sparse
            self.transform = transform

        ids = np.nonzero(wd)
        if len(ids[0]) == 0:
            return []
        else:
            ijs = list(zip(ids[0], ids[1]))
            ijs.sort()
            return ijs

    def symmetrize(self, inplace=False):
        """Construct a symmetric KNN weight. This ensures that the neighbors
        of each focal observation consider the focal observation itself as
        a neighbor. This returns a generic ``W`` object, since the object is no
        longer guaranteed to have ``k`` neighbors for each observation.
        """
        if not inplace:
            neighbors = copy.deepcopy(self.neighbors)
            weights = copy.deepcopy(self.weights)
            out_W = W(neighbors, weights, id_order=self.id_order)
            out_W.symmetrize(inplace=True)
            return out_W
        else:
            for focal, fneighbs in list(self.neighbors.items()):
                for j, neighbor in enumerate(fneighbs):
                    neighb_neighbors = self.neighbors[neighbor]
                    if focal not in neighb_neighbors:
                        self.neighbors[neighbor].append(focal)
                        self.weights[neighbor].append(self.weights[focal][j])
            self._cache = dict()
            return

    def full(self):
        """Generate a full ``numpy.ndarray``.

        Parameters
        ----------
        self : libpysal.weights.W
            spatial weights object

        Returns
        -------
        (fullw, keys) : tuple
            The first element being the full ``numpy.ndarray`` and second
            element keys being the ids associated with each row in the array.

        Examples
        --------
        >>> from libpysal.weights import W, full
        >>> neighbors = {'first':['second'],'second':['first','third'],'third':['second']}
        >>> weights = {'first':[1],'second':[1,1],'third':[1]}
        >>> w = W(neighbors, weights)
        >>> wf, ids = full(w)
        >>> wf
        array([[0., 1., 0.],
               [1., 0., 1.],
               [0., 1., 0.]])
        >>> ids
        ['first', 'second', 'third']
        """
        wfull = self.sparse.toarray()
        keys = list(self.neighbors.keys())
        if self.id_order:
            keys = self.id_order

        return (wfull, keys)

    def to_WSP(self):
        """Generate a ``WSP`` object.

        Returns
        -------

        implicit : libpysal.weights.WSP
            Thin ``W`` class

        See also
        --------
        WSP

        """
        return WSP(self.sparse, self._id_order)

    def set_shapefile(self, shapefile, idVariable=None, full=False):
        """
        Adding metadata for writing headers of ``.gal`` and ``.gwt`` files.

        Parameters
        ----------
        shapefile : str
            The shapefile name used to construct weights.
        idVariable : str
            The name of the attribute in the shapefile to associate
            with ids in the weights.
        full : bool
            Write out the entire path for a shapefile (``True``) or
            only the resources of the shapefile without extension (``False``).
            Default is ``True``.
        """

        if full:
            self._shpName = shapefile
        else:
            self._shpName = BASENAME(shapefile).split(".")[0]

        self._varName = idVariable

    def plot(
            self, gdf, indexed_on=None, ax=None, color="k", node_kws=None, edge_kws=None
    ):
        """Plot spatial weights objects. **Requires** ``matplotlib``, and
        implicitly requires a ``geopandas.GeoDataFrame`` as input.

        Parameters
        ----------
        gdf : geopandas.GeoDataFrame
            The original shapes whose topological relations are modelled in ``W``.
        indexed_on : str
            Column of ``geopandas.GeoDataFrame`` that the weights object uses
            as an index. Default is ``None``, so the index of the
            ``geopandas.GeoDataFrame`` is used.
        ax : matplotlib.axes.Axes
            Axis on which to plot the weights. Default is ``None``, so
            plots on the current figure.
        color : str
            ``matplotlib`` color string, will color both nodes and edges
            the same by default.
        node_kws : dict
            Keyword arguments dictionary to send to ``pyplot.scatter``,
            which provides fine-grained control over the aesthetics
            of the nodes in the plot.
        edge_kws : dict
            Keyword arguments dictionary to send to ``pyplot.plot``,
            which provides fine-grained control over the aesthetics
            of the edges in the plot.

        Returns
        -------
        f : matplotlib.figure.Figure
            Figure on which the plot is made.
        ax : matplotlib.axes.Axes
            Axis on which the plot is made.

        Notes
        -----
        If you'd like to overlay the actual shapes from the
        ``geopandas.GeoDataFrame``, call ``gdf.plot(ax=ax)`` after this.
        To plot underneath, adjust the z-order of the plot as follows:
        ``gdf.plot(ax=ax,zorder=0)``.

        """
        try:
            import matplotlib.pyplot as plt
        except ImportError:
            raise ImportError(
                "W.plot depends on matplotlib.pyplot, and this was"
                "not able to be imported. \nInstall matplotlib to"
                "plot spatial weights."
            )
        if ax is None:
            f = plt.figure()
            ax = plt.gca()
        else:
            f = plt.gcf()
        if node_kws is not None:
            if "color" not in node_kws:
                node_kws["color"] = color
        else:
            node_kws = dict(color=color)
        if edge_kws is not None:
            if "color" not in edge_kws:
                edge_kws["color"] = color
        else:
            edge_kws = dict(color=color)

        for idx, neighbors in self:
            if idx in self.islands:
                continue
            if indexed_on is not None:
                neighbors = gdf[gdf[indexed_on].isin(neighbors)].index.tolist()
                idx = gdf[gdf[indexed_on] == idx].index.tolist()[0]
            centroids = gdf.loc[neighbors].centroid.apply(lambda p: (p.x, p.y))
            centroids = np.vstack(centroids.values)
            focal = np.hstack(gdf.loc[idx].geometry.centroid.xy)
            seen = set()
            for nidx, neighbor in zip(neighbors, centroids):
                if (idx, nidx) in seen:
                    continue
                ax.plot(*list(zip(focal, neighbor)), marker=None, **edge_kws)
                seen.update((idx, nidx))
                seen.update((nidx, idx))
        ax.scatter(
            gdf.centroid.apply(lambda p: p.x),
            gdf.centroid.apply(lambda p: p.y),
            **node_kws
        )
        return f, ax


class WSP(object):
    """Thin ``W`` class for ``spreg``.

    Parameters
    ----------

    sparse : scipy.sparse.{matrix-type}
        NxN object from ``scipy.sparse``

    Attributes
    ----------

    n           : int
                  description
    s0          : float
                  description
    trcWtW_WW   : float
                  description

    """

    def __init__(self, sparse, id_order=None, index=None):
        if not scipy.sparse.issparse(sparse):
            raise ValueError("must pass a scipy sparse object")
        rows, cols = sparse.shape
        if rows != cols:
            raise ValueError("Weights object must be square")
        self.sparse = sparse.tocsr()
        self.n = sparse.shape[0]
        self._cache = {}
        if id_order:
            if len(id_order) != self.n:
                raise ValueError(
                    "Number of values in id_order must match shape of sparse"
                )
            else:
                self._id_order = id_order
                self._cache["id_order"] = self._id_order
        # temp addition of index attribute
        import pandas as pd  # will be removed after refactoring is done

        if index is not None:
            if not isinstance(index, (pd.Index, pd.MultiIndex, pd.RangeIndex)):
                raise TypeError("index must be an instance of pandas.Index dtype")
            if len(index) != self.n:
                raise ValueError("Number of values in index must match shape of sparse")
        else:
            index = pd.RangeIndex(self.n)
        self.index = index

    @property
    def id_order(self):
        """An ordered list of ids, assumed to match the ordering in ``sparse``."""
        # Temporary solution until the refactoring is finished
        if "id_order" not in self._cache:
            if hasattr(self, "index"):
                self._id_order = self.index.tolist()
            else:
                self._id_order = list(range(self.n))
            self._cache["id_order"] = self._id_order
        return self._id_order

    @property
    def s0(self):
        r"""``s0`` is defined as:

        .. math::

               s0=\sum_i \sum_j w_{i,j}

        """
        if "s0" not in self._cache:
            self._s0 = self.sparse.sum()
            self._cache["s0"] = self._s0
        return self._s0

    @property
    def trcWtW_WW(self):
        """Trace of :math:`W^{'}W + WW`."""
        if "trcWtW_WW" not in self._cache:
            self._trcWtW_WW = self.diagWtW_WW.sum()
            self._cache["trcWtW_WW"] = self._trcWtW_WW
        return self._trcWtW_WW

    @property
    def diagWtW_WW(self):
        """Diagonal of :math:`W^{'}W + WW`."""
        if "diagWtW_WW" not in self._cache:
            wt = self.sparse.transpose()
            w = self.sparse
            self._diagWtW_WW = (wt * w + w * w).diagonal()
            self._cache["diagWtW_WW"] = self._diagWtW_WW
        return self._diagWtW_WW

    @classmethod
    def from_W(cls, W):
        """Constructs a ``WSP`` object from the ``W``'s sparse matrix.

        Parameters
        ----------
        W : libpysal.weights.W
            A PySAL weights object with a sparse form and ids.

        Returns
        -------
        A ``WSP`` instance.
        """
        return cls(W.sparse, id_order=W.id_order)

    def to_W(self, silence_warnings=False):
        """
        Convert a pysal WSP object (thin weights matrix) to a pysal W object.

        Parameters
        ----------
        self : WSP
            PySAL sparse weights object.
        silence_warnings : bool
            Switch to ``True`` to turn off print statements for every
            observation with islands. Default is ``False``, which does
            not silence warnings.

        Returns
        -------
        w : W
            PySAL weights object.

        """

        indices = list(self.sparse.indices)
        data = list(self.sparse.data)
        indptr = list(self.sparse.indptr)
        id_order = self.id_order
        if id_order:
            # replace indices with user IDs
            indices = [id_order[i] for i in indices]
        else:
            id_order = list(range(self.n))
        neighbors, weights = {}, {}
        start = indptr[0]
        for i in range(self.n):
            oid = id_order[i]
            end = indptr[i + 1]
            neighbors[oid] = indices[start:end]
            weights[oid] = data[start:end]
            start = end
        ids = copy.copy(self.id_order)
        w = W(neighbors, weights, ids, silence_warnings=silence_warnings)
        w._sparse = copy.deepcopy(self.sparse)
        w._cache["sparse"] = w._sparse
        return w
