from __future__ import annotations

import numpy as np

from pytools import memoize_method

import pycuda.gpuarray as gpuarray
from pycuda.compiler import SourceModule


PKT_KERNEL_TEMPLATE = """
typedef %(index_type)s index_type;
typedef %(value_type)s value_type;
typedef %(packed_index_type)s packed_index_type;

#define ROWS_PER_PACKET %(rows_per_packet)d
#define THREADS_PER_PACKET %(threads_per_packet)d

template <typename IndexType, typename ValueType>
__device__ void memcpy_device(
  ValueType *dest, const ValueType *src,
  const IndexType num_values)
{
  for(unsigned int i = threadIdx.x; i < num_values; i += blockDim.x)
  {
    dest[i] = src[i];
  }
}

#define pkt_unpack_row_index(packed_index) ( packed_index >> 16  )
#define pkt_unpack_col_index(packed_index) (packed_index & 0xFFFF)

extern "C" {
__global__ void
spmv_pkt_kernel(const index_type *row_ptr,
                const index_type *pos_start,
                const index_type *pos_end,
                const packed_index_type *index_array,
                const value_type *data_array,
                const value_type *x,
                      value_type *y)
{
  __shared__ value_type s_x[ROWS_PER_PACKET]; // input x-values
  __shared__ value_type s_y[ROWS_PER_PACKET]; // output y-values

  const index_type thread_id =
    __umul24(THREADS_PER_PACKET, blockIdx.x) + threadIdx.x;

  // base index of the submatrix corresponding to this packet
  const index_type packet_base_row = row_ptr[blockIdx.x];
  const index_type packet_num_rows = row_ptr[blockIdx.x+1] - packet_base_row;

  // copy local x and y values from global memory into shared memory
  memcpy_device(s_x, x + packet_base_row, packet_num_rows);
  memcpy_device(s_y, y + packet_base_row, packet_num_rows);

  __syncthreads();

  // process packet

  const index_type packet_start = pos_start[thread_id];
  const index_type packet_end = pos_end[thread_id];

  for(index_type pos = packet_start; pos != packet_end; pos += THREADS_PER_PACKET)
  {
    // row and column indices are stored in the same 32-bit word

    const index_type packed_index = index_array[pos];

    const index_type row = pkt_unpack_row_index(packed_index);
    const index_type col = pkt_unpack_col_index(packed_index);
    const value_type val = data_array[pos];

    s_y[row] += val * s_x[col];
  }

  __syncthreads();

  // copy y-values from shared memory to global array

  memcpy_device(y + packet_base_row, s_y, packet_num_rows);
}
}
"""


class PacketedSpMV:
    def __init__(self, mat, is_symmetric, dtype):
        from pycuda.tools import DeviceData

        devdata = DeviceData()

        # all row indices in the data structure generation code are
        # "unpermuted" unless otherwise specified
        self.dtype = np.dtype(dtype)
        self.index_dtype = np.int32
        self.packed_index_dtype = np.uint32
        self.threads_per_packet = devdata.max_threads

        h, w = self.shape = mat.shape
        if h != w:
            raise ValueError("only square matrices are supported")

        self.rows_per_packet = (devdata.shared_memory - 100) // (
            2 * self.dtype.itemsize
        )

        self.block_count = (h + self.rows_per_packet - 1) // self.rows_per_packet

        # get metis partition -------------------------------------------------
        from scipy.sparse import csr_matrix

        csr_mat = csr_matrix(mat, dtype=self.dtype)

        from pymetis import part_graph

        if not is_symmetric:
            # make sure adjacency graph is undirected
            adj_mat = csr_mat + csr_mat.T
        else:
            adj_mat = csr_mat

        while True:
            _cut_count, dof_to_packet_nr = part_graph(
                int(self.block_count), xadj=adj_mat.indptr, adjncy=adj_mat.indices
            )

            # build packet_nr_to_dofs
            packet_nr_to_dofs = {}
            for i, packet_nr in enumerate(dof_to_packet_nr):
                try:
                    dof_packet = packet_nr_to_dofs[packet_nr]
                except KeyError:
                    packet_nr_to_dofs[packet_nr] = dof_packet = []

                dof_packet.append(i)

            packet_nr_to_dofs = [
                packet_nr_to_dofs.get(i) for i in range(len(packet_nr_to_dofs))
            ]

            too_big = False
            for packet_dofs in packet_nr_to_dofs:
                if len(packet_dofs) >= self.rows_per_packet:
                    too_big = True
                    break

            if too_big:
                old_block_count = self.block_count
                self.block_count = int(2 + 1.05 * self.block_count)
                print(

                        "Metis produced a big block at block count "
                        "%d--retrying with %d" % (old_block_count, self.block_count)

                )
                continue

            break

        assert len(packet_nr_to_dofs) == self.block_count

        # permutations, base rows ---------------------------------------------
        (
            new2old_fetch_indices,
            old2new_fetch_indices,
            packet_base_rows,
        ) = self.find_simple_index_stuff(packet_nr_to_dofs)

        # find local row cost and remaining_coo -------------------------------
        local_row_costs, remaining_coo = self.find_local_row_costs_and_remaining_coo(
            csr_mat, dof_to_packet_nr, old2new_fetch_indices
        )
        local_nnz = np.sum(local_row_costs)

        assert remaining_coo.nnz == csr_mat.nnz - local_nnz

        # find thread assignment for each block -------------------------------
        thread_count = len(packet_nr_to_dofs) * self.threads_per_packet
        thread_assignments, thread_costs = self.find_thread_assignment(
            packet_nr_to_dofs, local_row_costs, thread_count
        )

        max_thread_costs = np.max(thread_costs)

        # build data structure ------------------------------------------------
        from .pkt_build import build_pkt_data_structure

        build_pkt_data_structure(
            self,
            packet_nr_to_dofs,
            max_thread_costs,
            old2new_fetch_indices,
            csr_mat,
            thread_count,
            thread_assignments,
            local_row_costs,
        )

        self.packet_base_rows = gpuarray.to_gpu(packet_base_rows)
        self.new2old_fetch_indices = gpuarray.to_gpu(new2old_fetch_indices)
        self.old2new_fetch_indices = gpuarray.to_gpu(old2new_fetch_indices)

        from .coordinate import CoordinateSpMV

        self.remaining_coo_gpu = CoordinateSpMV(remaining_coo, dtype)

    def find_simple_index_stuff(self, packet_nr_to_dofs):
        new2old_fetch_indices = np.zeros(self.shape[0], dtype=self.index_dtype)
        old2new_fetch_indices = np.zeros(self.shape[0], dtype=self.index_dtype)

        packet_base_rows = np.zeros(self.block_count + 1, dtype=self.index_dtype)

        row_start = 0
        for packet_nr, packet in enumerate(packet_nr_to_dofs):
            packet_base_rows[packet_nr] = row_start
            row_end = row_start + len(packet)

            pkt_indices = np.array(packet, dtype=self.index_dtype)
            new2old_fetch_indices[row_start:row_end] = pkt_indices
            old2new_fetch_indices[pkt_indices] = np.arange(
                row_start, row_end, dtype=self.index_dtype
            )

            row_start += len(packet)

        packet_base_rows[self.block_count] = row_start

        return (new2old_fetch_indices, old2new_fetch_indices, packet_base_rows)

    def find_local_row_costs_and_remaining_coo(
        self, csr_mat, dof_to_packet_nr, old2new_fetch_indices
    ):
        h, _w = self.shape
        local_row_costs = [0] * h
        rem_coo_values = []
        rem_coo_i = []
        rem_coo_j = []

        iptr = csr_mat.indptr
        indices = csr_mat.indices
        data = csr_mat.data

        for i in range(h):
            for idx in range(iptr[i], iptr[i + 1]):
                j = indices[idx]

                if dof_to_packet_nr[i] == dof_to_packet_nr[j]:
                    local_row_costs[i] += 1
                else:
                    rem_coo_values.append(data[idx])
                    rem_coo_i.append(old2new_fetch_indices[i])
                    rem_coo_j.append(old2new_fetch_indices[j])

        from scipy.sparse import coo_matrix

        remaining_coo = coo_matrix(
            (rem_coo_values, (rem_coo_i, rem_coo_j)), self.shape, dtype=self.dtype
        )

        return local_row_costs, remaining_coo

    def find_thread_assignment(self, packet_nr_to_dofs, local_row_cost, thread_count):
        thread_assignments = [[] for i in range(thread_count)]
        thread_costs = np.zeros(thread_count)

        for packet_nr, packet_dofs in enumerate(packet_nr_to_dofs):
            row_costs_and_numbers = sorted(
                [(local_row_cost[i], i) for i in packet_dofs], reverse=True
            )

            base_thread_nr = packet_nr * self.threads_per_packet
            thread_offset = 0

            # zigzag assignment
            step = 1
            for row_cost, row_number in row_costs_and_numbers:
                ti = base_thread_nr + thread_offset
                thread_assignments[ti].append(row_number)
                thread_costs[ti] += row_cost

                if thread_offset + step >= self.threads_per_packet:
                    step = -1
                elif thread_offset + step < 0:
                    step = 1
                else:
                    thread_offset += step

        return thread_assignments, thread_costs

    def build_gpu_data_structure(
        self,
        packet_nr_to_dofs,
        max_thread_costs,
        old2new_fetch_indices,
        csr_mat,
        thread_count,
        thread_assignments,
        local_row_costs,
    ):
        # these arrays will likely be too long, but that's ok

        from .pkt_build import build_pkt_structure

        build_pkt_structure(
            self,
            packet_nr_to_dofs,
            thread_assignments,
            # thread_starts,
            # thread_ends,
            # index_array,
            # data_array,
        )

        # copy data to the gpu ------------------------------------------------

    # execution ---------------------------------------------------------------
    @memoize_method
    def get_kernel(self):
        from pycuda.tools import dtype_to_ctype

        mod = SourceModule(
            PKT_KERNEL_TEMPLATE
            % {
                "value_type": dtype_to_ctype(self.dtype),
                "index_type": dtype_to_ctype(self.index_dtype),
                "packed_index_type": dtype_to_ctype(self.packed_index_dtype),
                "threads_per_packet": self.threads_per_packet,
                "rows_per_packet": self.rows_per_packet,
            },
            no_extern_c=True,
        )
        func = mod.get_function("spmv_pkt_kernel")
        func.prepare("PPPPPPP")
        return func

    def permute(self, x):
        return gpuarray.take(x, self.new2old_fetch_indices)

    def unpermute(self, x):
        return gpuarray.take(x, self.old2new_fetch_indices)

    def __call__(self, x, y=None):
        if y is None:
            y = gpuarray.zeros(self.shape[0], dtype=self.dtype, allocator=x.allocator)

        self.get_kernel().prepared_call(
            (self.block_count, 1),
            (self.threads_per_packet, 1, 1),
            self.packet_base_rows.gpudata,
            self.thread_starts.gpudata,
            self.thread_ends.gpudata,
            self.index_array.gpudata,
            self.data_array.gpudata,
            x.gpudata,
            y.gpudata,
        )

        self.remaining_coo_gpu(x, y)

        return y
