import math
from abc import ABC, abstractmethod

import numpy as np
import sympy as sp


def get_primes_less_than(max_v: int) -> list[int]:
    return SieveFactory.get(max_v).primes(max_v)

def get_approximate_num_primes_less_than(max_v: int) -> int:
    """
    prime number theorem (Gauss [1863, though first mentioned by him in 1849])

       pi(N) ~= integral of 1/ln(x) over the range 2 to max_v

    :param max_v:
    :return: approximate number of prime numbers less than max_v
    """
    if max_v < 3:
        return 0
    x = sp.Symbol('x')
    return int(round(sp.Integral(1 / sp.log(x), (x, 2, max_v)).evalf()))


class SieveFactory:
    @staticmethod
    def get(max_val)->Sieve:
        return Eratosthenes() if max_val < 800_000_000 else IndexedEratosthenes()

class Sieve(ABC):

    @abstractmethod
    def primes(self, max_val:int)->list[int]: pass

class Eratosthenes(Sieve):
    """
    Classic Eratosthenes, leveraging numpy
    """

    def primes(self, max_val: int) -> list[int]:
        return self._core(max_val) if max_val >= 3 else []

    @staticmethod
    def _core(max_val) -> list[int]:
        sieve = np.ones(max_val + 1, dtype=bool)
        sieve[0] = sieve[1] = sieve[2::2] = False
        max_sqrt_value = math.ceil(math.sqrt(max_val + 1)) + 1
        for prime in (value for value in range(3, max_sqrt_value, 2) if sieve[value]):
            sieve[prime * prime::prime] = False
        return list(np.where(sieve)[0].astype(int))


class IndexedEratosthenes(Sieve):
    """
    Indexed Eratosthenes, leveraging numpy
    unlike the original, this uses an array representing only odd values from 3 -> max value
    this gives a smaller array to sieve, but includes extra calculations for to/from numbers to index
    """

    def primes(self, max_val: int) -> list[int]:
        return self._core(max_val) if max_val >= 3 else []

    @staticmethod
    def _to_val(index: int) -> int:
        # goes from an index in our odds array to the number it represents
        return 2 * index + 3

    @staticmethod
    def _to_index(value: float | int) -> int:
        # goes from a number to an index in our odds arra
        return round(value * .5 - 1.5)

    @staticmethod
    def _core(max_val: int) -> list[int]:
        max_val = max_val - 1 if max_val % 2 == 0 else max_val
        sieve = np.ones(IndexedEratosthenes._to_index(max_val) + 1, dtype=bool)
        max_sqrt_value_index = IndexedEratosthenes._to_index(math.ceil(math.sqrt(max_val + 1)) + 1)
        for p_val in (IndexedEratosthenes._to_val(p_index) for p_index in range(0, max_sqrt_value_index + 1) if
                      sieve[p_index]):
            sieve[IndexedEratosthenes._to_index(p_val * p_val)::p_val] = False
        return [IndexedEratosthenes._to_val(p_index) for p_index in np.where(sieve)[0]]
