from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from typing import DefaultDict, Dict, Iterable, List, Optional, Union

from .constants import SECS_IN_DAY, SECS_IN_HR
from .helpers import ConstellationId, get_constellation, get_closest, get_el_az, TimeRangeHolder
from .ephemeris import Ephemeris, EphemerisType, GLONASSEphemeris, GPSEphemeris, PolyEphemeris, parse_sp3_orbits, parse_rinex_nav_msg_gps, \
  parse_rinex_nav_msg_glonass
from .downloader import download_orbits_gps, download_orbits_russia_src, download_nav, download_ionex, download_dcb, download_prediction_orbits_russia_src
from .downloader import download_cors_station
from .trop import saast
from .iono import IonexMap, parse_ionex, get_slant_delay
from .dcb import DCB, parse_dcbs
from .gps_time import GPSTime
from .dgps import get_closest_station_names, parse_dgps
from . import constants

MAX_DGPS_DISTANCE = 100_000  # in meters, because we're not barbarians


class AstroDog:
  '''
  auto_update: flag indicating whether laika should fetch files from web automatically
  cache_dir:   directory where data files are downloaded to and cached
  dgps:        flag indicating whether laika should use dgps (CORS)
               data to calculate pseudorange corrections
  valid_const: list of constellation identifiers laika will try process
  valid_ephem_types: set of ephemeris types that are allowed to use and download.
                Default is set to use all orbit ephemeris types
  clear_old_ephemeris: flag indicating if ephemeris for an individual satellite should be overwritten when new ephemeris is added.
  '''

  def __init__(self, auto_update=True,
               cache_dir='/tmp/gnss/',
               dgps=False,
               valid_const=(ConstellationId.GPS, ConstellationId.GLONASS),
               valid_ephem_types=EphemerisType.all_orbits(),
               clear_old_ephemeris=False):

    for const in valid_const:
      if not isinstance(const, ConstellationId):
        raise TypeError(f"valid_const must be a list of ConstellationId, got {const}")
  
    self.auto_update = auto_update
    self.cache_dir = cache_dir
    self.clear_old_ephemeris = clear_old_ephemeris
    self.dgps = dgps
    if not isinstance(valid_ephem_types, Iterable):
      valid_ephem_types = [valid_ephem_types]
    self.pull_orbit = len(set(EphemerisType.all_orbits()) & set(valid_ephem_types)) > 0
    self.pull_nav = EphemerisType.NAV in valid_ephem_types
    self.use_qcom_poly = EphemerisType.QCOM_POLY in valid_ephem_types
    self.valid_const = valid_const
    self.valid_ephem_types = valid_ephem_types

    self.orbit_fetched_times = TimeRangeHolder()
    self.navs_fetched_times = TimeRangeHolder()
    self.dcbs_fetched_times = TimeRangeHolder()

    self.dgps_delays = []
    self.ionex_maps: List[IonexMap] = []
    self.orbits: DefaultDict[str, List[PolyEphemeris]] = defaultdict(list)
    self.qcom_polys: DefaultDict[str, List[PolyEphemeris]] = defaultdict(list)
    self.navs: DefaultDict[str, List[Union[GPSEphemeris, GLONASSEphemeris]]] = defaultdict(list)
    self.dcbs: DefaultDict[str, List[DCB]] = defaultdict(list)

    self.cached_ionex: Optional[IonexMap] = None
    self.cached_dgps = None
    self.cached_orbit: DefaultDict[str, Optional[PolyEphemeris]] = defaultdict(lambda: None)
    self.cached_qcom_polys: DefaultDict[str, Optional[PolyEphemeris]] = defaultdict(lambda: None)
    self.cached_nav: DefaultDict[str, Union[GPSEphemeris, GLONASSEphemeris, None]] = defaultdict(lambda: None)
    self.cached_dcb: DefaultDict[str, Optional[DCB]] = defaultdict(lambda: None)

  def get_ionex(self, time) -> Optional[IonexMap]:
    ionex: Optional[IonexMap] = self._get_latest_valid_data(self.ionex_maps, self.cached_ionex, self.get_ionex_data, time)
    if ionex is None:
      if self.auto_update:
        raise RuntimeError("Pulled ionex, but still can't get valid for time " + str(time))
    else:
      self.cached_ionex = ionex
    return ionex

  def get_nav(self, prn, time):
    skip_download = time in self.navs_fetched_times
    nav = self._get_latest_valid_data(self.navs[prn], self.cached_nav[prn], self.get_nav_data, time, skip_download)
    if nav is not None:
      self.cached_nav[prn] = nav
    return nav

  @staticmethod
  def _select_valid_temporal_items(item_dict, time, cache):
    '''Returns only valid temporal item for specific time from currently fetched
    data.'''
    result = {}
    for prn, temporal_objects in item_dict.items():
      cached = cache[prn]
      if cached is not None and cached.valid(time):
        obj = cached
      else:
        obj = get_closest(time, temporal_objects)
        if obj is None or not obj.valid(time):
          continue
        cache[prn] = obj
      result[prn] = obj
    return result

  def get_all_ephem_prns(self):
    return set(self.orbits.keys()).union(set(self.navs.keys())).union(set(self.qcom_polys.keys()))

  def get_navs(self, time):
    if time not in self.navs_fetched_times:
      self.get_nav_data(time)
    return AstroDog._select_valid_temporal_items(self.navs, time, self.cached_nav)

  def get_orbit(self, prn: str, time: GPSTime):
    skip_download = time in self.orbit_fetched_times
    orbit = self._get_latest_valid_data(self.orbits[prn], self.cached_orbit[prn], self.get_orbit_data, time, skip_download)
    if orbit is not None:
      self.cached_orbit[prn] = orbit
    return orbit

  def get_qcom_poly(self, prn: str, time: GPSTime):
    poly = self._get_latest_valid_data(self.qcom_polys[prn], self.cached_qcom_polys[prn], None, time, True)
    if poly is not None:
      self.cached_qcom_polys[prn] = poly
    return poly

  def get_orbits(self, time):
    if time not in self.orbit_fetched_times:
      self.get_orbit_data(time)
    return AstroDog._select_valid_temporal_items(self.orbits, time, self.cached_orbit)

  def get_dcb(self, prn, time):
    skip_download = time in self.dcbs_fetched_times
    dcb = self._get_latest_valid_data(self.dcbs[prn], self.cached_dcb[prn], self.get_dcb_data, time, skip_download)
    if dcb is not None:
      self.cached_dcb[prn] = dcb
    return dcb

  def get_dgps_corrections(self, time, recv_pos):
    latest_data = self._get_latest_valid_data(self.dgps_delays, self.cached_dgps, self.get_dgps_data, time, recv_pos=recv_pos)
    if latest_data is None:
      if self.auto_update:
        raise RuntimeError("Pulled dgps, but still can't get valid for time " + str(time))
    else:
      self.cached_dgps = latest_data
    return latest_data

  def add_qcom_polys(self, new_ephems: Dict[str, List[Ephemeris]]):
    self._add_ephems(new_ephems, self.qcom_polys)

  def add_orbits(self, new_ephems: Dict[str, List[Ephemeris]]):
    self._add_ephems(new_ephems, self.orbits)

  def add_navs(self, new_ephems: Dict[str, List[Ephemeris]]):
    self._add_ephems(new_ephems, self.navs)

  def _add_ephems(self, new_ephems: Dict[str, List[Ephemeris]], ephems_dict):
    for k, v in new_ephems.items():
      if len(v) > 0:
        if self.clear_old_ephemeris:
          ephems_dict[k] = v
        else:
          ephems_dict[k].extend(v)

  def add_ephem_fetched_time(self, ephems, fetched_times):
    min_epochs = []
    max_epochs = []
    for v in ephems.values():
      if len(v) > 0:
        min_ephem, max_ephem = self.get_epoch_range(v)
        min_epochs.append(min_ephem)
        max_epochs.append(max_ephem)
    if len(min_epochs) > 0:
      min_epoch = min(min_epochs)
      max_epoch = max(max_epochs)
      fetched_times.add(min_epoch, max_epoch)

  def get_nav_data(self, time):
    def download_and_parse(constellation, parse_rinex_nav_func):
      file_path = download_nav(time, cache_dir=self.cache_dir, constellation=constellation)
      return parse_rinex_nav_func(file_path) if file_path else {}

    fetched_ephems = {}

    if ConstellationId.GPS in self.valid_const:
      fetched_ephems = download_and_parse(ConstellationId.GPS, parse_rinex_nav_msg_gps)
    if ConstellationId.GLONASS in self.valid_const:
      for k, v in download_and_parse(ConstellationId.GLONASS, parse_rinex_nav_msg_glonass).items():
        fetched_ephems.setdefault(k, []).extend(v)
    self.add_navs(fetched_ephems)

    if sum([len(v) for v in fetched_ephems.values()]) == 0:
      begin_day = GPSTime(time.week, SECS_IN_DAY * (time.tow // SECS_IN_DAY))
      end_day = GPSTime(time.week, SECS_IN_DAY * (1 + (time.tow // SECS_IN_DAY)))
      self.navs_fetched_times.add(begin_day, end_day)

  def download_parse_orbit(self, gps_time: GPSTime, skip_before_epoch=None) -> Dict[str, List[PolyEphemeris]]:
    # Download multiple days to be able to polyfit at the start-end of the day
    time_steps = [gps_time - SECS_IN_DAY, gps_time, gps_time + SECS_IN_DAY]
    with ThreadPoolExecutor() as executor:
      futures_other = [executor.submit(download_orbits_russia_src, t, self.cache_dir, self.valid_ephem_types) for t in time_steps]
      futures_gps = None
      if ConstellationId.GPS in self.valid_const:
        futures_gps = [executor.submit(download_orbits_gps, t, self.cache_dir, self.valid_ephem_types) for t in time_steps]

      ephems_other = parse_sp3_orbits([f.result() for f in futures_other if f.result()], self.valid_const, skip_before_epoch)
      ephems_us = parse_sp3_orbits([f.result() for f in futures_gps if f.result()], self.valid_const, skip_before_epoch) if futures_gps else {}

    return {k: ephems_other.get(k, []) + ephems_us.get(k, []) for k in set(list(ephems_other.keys()) + list(ephems_us.keys()))}

  def download_parse_prediction_orbit(self, gps_time: GPSTime):
    assert EphemerisType.ULTRA_RAPID_ORBIT in self.valid_ephem_types
    skip_until_epoch = gps_time - 2 * SECS_IN_HR

    result = download_prediction_orbits_russia_src(gps_time, self.cache_dir)
    if result is not None:
      result = [result]
    elif ConstellationId.GPS in self.valid_const:
      # Slower fallback. Russia src prediction orbits are published from 2022
      result = [download_orbits_gps(t, self.cache_dir, self.valid_ephem_types) for t in [gps_time - SECS_IN_DAY, gps_time]]
    if result is None:
      return {}
    return parse_sp3_orbits(result, self.valid_const, skip_until_epoch=skip_until_epoch)

  def get_orbit_data(self, time: GPSTime, only_predictions=False):
    if only_predictions:
      ephems_sp3 = self.download_parse_prediction_orbit(time)
    else:
      ephems_sp3 = self.download_parse_orbit(time)
    if sum([len(v) for v in ephems_sp3.values()]) < 5:
      raise RuntimeError(f'No orbit data found. For Time {time.as_datetime()} constellations {self.valid_const} valid ephem types {self.valid_ephem_types}')
    self.add_ephem_fetched_time(ephems_sp3, self.orbit_fetched_times)
    self.add_orbits(ephems_sp3)

  def get_dcb_data(self, time):
    file_path_dcb = download_dcb(time, cache_dir=self.cache_dir)
    dcbs = parse_dcbs(file_path_dcb, self.valid_const)
    for dcb in dcbs:
      self.dcbs[dcb.prn].append(dcb)

    if len(dcbs) != 0:
      min_epoch, max_epoch = self.get_epoch_range(dcbs)
      self.dcbs_fetched_times.add(min_epoch, max_epoch)

  def get_epoch_range(self, new_ephems):
    min_ephem = min(new_ephems, key=lambda e: e.epoch)
    max_ephem = max(new_ephems, key=lambda e: e.epoch)
    min_epoch = min_ephem.epoch - min_ephem.max_time_diff
    max_epoch = max_ephem.epoch + max_ephem.max_time_diff
    return min_epoch, max_epoch

  def get_ionex_data(self, time):
    file_path_ionex = download_ionex(time, cache_dir=self.cache_dir)
    ionex_maps = parse_ionex(file_path_ionex)
    for im in ionex_maps:
      self.ionex_maps.append(im)

  def get_dgps_data(self, time, recv_pos):
    station_names = get_closest_station_names(recv_pos, k=8, max_distance=MAX_DGPS_DISTANCE, cache_dir=self.cache_dir)
    for station_name in station_names:
      file_path_station = download_cors_station(time, station_name, cache_dir=self.cache_dir)
      if file_path_station:
        dgps = parse_dgps(station_name, file_path_station,
                          self, max_distance=MAX_DGPS_DISTANCE,
                          required_constellations=self.valid_const)
        if dgps is not None:
          self.dgps_delays.append(dgps)
          break

  def get_tgd_from_nav(self, prn, time):
    if get_constellation(prn) not in self.valid_const:
      return None

    eph = self.get_nav(prn, time)

    if eph:
      return eph.get_tgd()
    return None

  def get_eph(self, prn, time):
    if get_constellation(prn) not in self.valid_const:
      return None
    eph = None
    if self.pull_orbit:
      eph = self.get_orbit(prn, time)
    if not eph and self.pull_nav:
      eph = self.get_nav(prn, time)
    if not eph and self.use_qcom_poly:
      eph = self.get_qcom_poly(prn, time)
    return eph

  def get_sat_info(self, prn, time):
    eph = self.get_eph(prn, time)
    if eph:
      return eph.get_sat_info(time)
    return None

  def get_all_sat_info(self, time):
    ephs = {}
    if self.pull_orbit:
      ephs = self.get_orbits(time)
    if len(ephs) == 0 and self.pull_nav:
      ephs = self.get_navs(time)

    return {prn: eph.get_sat_info(time) for prn, eph in ephs.items()}

  def get_glonass_channel(self, prn, time):
    nav = self.get_nav(prn, time)
    if nav:
      return nav.channel
    return None

  def get_frequency(self, prn, time, signal='C1C'):
    if get_constellation(prn) == ConstellationId.GPS:
      switch = {'1': constants.GPS_L1,
                '2': constants.GPS_L2,
                '5': constants.GPS_L5,
                '6': constants.GALILEO_E6,
                '7': constants.GALILEO_E5B,
                '8': constants.GALILEO_E5AB}
      freq = switch.get(signal[1])
      if freq:
        return freq
      raise NotImplementedError("Dont know this GPS frequency: ", signal, prn)
    elif get_constellation(prn) == ConstellationId.GLONASS:
      n = self.get_glonass_channel(prn, time)
      if n is None:
        return None
      switch = {'1': constants.GLONASS_L1 + n * constants.GLONASS_L1_DELTA,
                '2': constants.GLONASS_L2 + n * constants.GLONASS_L2_DELTA,
                '5': constants.GLONASS_L5 + n * constants.GLONASS_L5_DELTA,
                '6': constants.GALILEO_E6,
                '7': constants.GALILEO_E5B,
                '8': constants.GALILEO_E5AB}
      freq = switch.get(signal[1])
      if freq:
        return freq
      raise NotImplementedError("Dont know this GLONASS frequency: ", signal, prn)

  def get_delay(self, prn, time, rcv_pos, no_dgps=False, signal='C1C', freq=None):
    sat_info = self.get_sat_info(prn, time)
    if sat_info is None:
      return None
    sat_pos = sat_info[0]
    el, az = get_el_az(rcv_pos, sat_pos)
    if el < 0.2:
      return None
    if self.dgps and not no_dgps:
      return self._get_delay_dgps(prn, rcv_pos, time)

    ionex = self.get_ionex(time)
    if not freq and ionex is not None:
      freq = self.get_frequency(prn, time, signal)
    dcb = self.get_dcb(prn, time)
    # When using internet we expect all data or return None
    if self.auto_update and (ionex is None or dcb is None or freq is None):
      return None
    if ionex is not None:
      iono_delay = ionex.get_delay(rcv_pos, az, el, sat_pos, time, freq)
    else:
      # 5m vertical delay is a good default
      iono_delay = get_slant_delay(rcv_pos, az, el, sat_pos, time, freq, vertical_delay=5.0)
    trop_delay = saast(rcv_pos, el)
    code_bias = dcb.get_delay(signal) if dcb is not None else 0.
    return iono_delay + trop_delay + code_bias

  def _get_delay_dgps(self, prn, rcv_pos, time):
    dgps_corrections = self.get_dgps_corrections(time, rcv_pos)
    if dgps_corrections is None:
      return None
    return dgps_corrections.get_delay(prn, time)

  def _get_latest_valid_data(self, data, latest_data, download_data_func, time, skip_download=False, recv_pos=None):
    def is_valid(latest_data):
      if recv_pos is None:
        return latest_data is not None and latest_data.valid(time)
      else:
        return latest_data is not None and latest_data.valid(time, recv_pos)

    if is_valid(latest_data):
      return latest_data

    latest_data = get_closest(time, data, recv_pos=recv_pos)
    if is_valid(latest_data):
      return latest_data
    if skip_download or not self.auto_update:
      return None
    if recv_pos is not None:
      download_data_func(time, recv_pos)
    else:
      download_data_func(time)
    latest_data = get_closest(time, data, recv_pos=recv_pos)
    if is_valid(latest_data):
      return latest_data
    return None
