# -*- coding: utf-8 -*-
"""
Compute wind setbacks exclusions
"""
from abc import ABC
from concurrent.futures import as_completed
import fiona
import geopandas as gpd
import logging
import numpy as np
import os
from rasterio import features
import re
from shapely.geometry import shape
from warnings import warn

from rex.utilities import parse_table, SpawnProcessPool, log_mem
from reV.handlers.exclusions import ExclusionLayers
from reVX.utilities.exclusions_converter import ExclusionsConverter
from reVX.utilities.utilities import log_versions

logger = logging.getLogger(__name__)


class BaseWindSetbacks(ABC):
    """
    Create exclusions layers for wind setbacks
    """
    MULTIPLIERS = {'high': 3, 'moderate': 1.1}

    def __init__(self, excl_fpath, hub_height, rotor_diameter, regs_fpath=None,
                 multiplier=None, hsds=False, chunks=(128, 128)):
        """
        Parameters
        ----------
        excl_fpath : str
            Path to .h5 file containing exclusion layers, will also be the
            location of any new setback layers
        hub_height : float | int
            Turbine hub height (m), used along with rotor diameter to compute
            blade tip height which is used to determine setback distance
        rotor_diameter : float | int
            Turbine rotor diameter (m), used along with hub height to compute
            blade tip height which is used to determine setback distance
        regs_fpath : str | None, optional
            Path to wind regulations .csv file, if None create generic
            setbacks using max-tip height * "multiplier", by default None
        multiplier : int | float | str | None, optional
            setback multiplier to use if wind regulations are not supplied,
            if str, must a key in {'high': 3, 'moderate': 1.1}, if supplied
            along with regs_fpath, will be ignored, multiplied with
            max-tip height, by default None
        hsds : bool, optional
            Boolean flag to use h5pyd to handle .h5 'files' hosted on AWS
            behind HSDS, by default False
        chunks : tuple, optional
            Chunk size to use for setback layers, if None use default chunk
            size in excl_fpath, by default (128, 128)
        """
        log_versions(logger)
        self._excl_fpath = excl_fpath
        self._hub_height = hub_height
        self._rotor_diameter = rotor_diameter
        self._hsds = hsds
        self._shape, self._chunks, self._profile = \
            self._parse_excl_properties(excl_fpath, chunks, hsds=hsds)

        self._regs, self._multi = self._preflight_check(regs_fpath, multiplier)

    def __repr__(self):
        msg = "{} for {}".format(self.__class__.__name__, self._excl_fpath)
        return msg

    @property
    def hub_height(self):
        """
        Turbine hub-height in meters

        Returns
        -------
        float
        """
        return self._hub_height

    @property
    def rotor_diameter(self):
        """
        Turbine rotor diameter in meters

        Returns
        -------
        float
        """
        return self._rotor_diameter

    @property
    def tip_height(self):
        """
        Turbine blade tip height in meters

        Returns
        -------
        float
        """
        return self._hub_height + self._rotor_diameter / 2

    @property
    def generic_setback(self):
        """
        Default setback of turbine tip height * multiplier, used for global
        setbacks

        Returns
        -------
        float
        """
        if self._multi is None:
            setback = None
        else:
            setback = self.tip_height * self._multi

        return setback

    @property
    def wind_regs(self):
        """
        Wind Regulations table

        Returns
        -------
        geopands.GeoDataFrame | None
        """
        return self._regs

    @property
    def multiplier(self):
        """
        Generic setback multiplier

        Returns
        -------
        int | float
        """
        return self._multi

    @property
    def arr_shape(self):
        """
        Rasterize array shape

        Returns
        -------
        tuple
        """
        return self._shape

    @property
    def profile(self):
        """
        Geotiff profile

        Returns
        -------
        dict
        """
        return self._profile

    @property
    def crs(self):
        """
        Coordinate reference system

        Returns
        -------
        str
        """
        return self.profile['crs']

    @staticmethod
    def _parse_excl_properties(excl_fpath, chunks, hsds=False):
        """
        Parse exclusions shape, chunk size, and profile from excl_fpath file

        Parameters
        ----------
        excl_fpath : str
            Path to .h5 file containing exclusion layers, will also be the
            location of any new setback layers
        chunks : tuple | None
            Chunk size of exclusions datasets
        hsds : bool, optional
            Boolean flag to use h5pyd to handle .h5 'files' hosted on AWS
            behind HSDS, by default False

        Returns
        -------
        shape : tuple
            Shape of exclusions datasets
        chunks : tuple | None
            Chunk size of exclusions datasets
        profile : str
            GeoTiff profile for exclusions datasets
        """
        with ExclusionLayers(excl_fpath, hsds=hsds) as exc:
            shape = exc.shape
            profile = exc.profile
            if chunks is None:
                chunks = exc.chunks

        if len(chunks) < 3:
            chunks = (1, ) + chunks

        if len(shape) < 3:
            shape = (1, ) + shape

        logger.debug('Exclusions properties:\n'
                     'shape : {}\n'
                     'chunks : {}\n'
                     'profile : {}\n'
                     .format(shape, chunks, profile))

        return shape, chunks, profile

    @staticmethod
    def _get_setback(cnty_regs, hub_height, rotor_diameter):
        """
        Compute the setback distance in meters from the county regulations,
        turbine tip height or rotor diameter

        Parameters
        ----------
        cnty_regs : pandas.Series
            Pandas Series with wind regulations for a single county / feature
            type
        hub_height : int | float
            Turbine hub-height, used to compute setbacks from hub-height
            multiplier regulations and to compute tip-height for tip-height
            multiplier regulations
        rotor_diameter : int | float
            Turbine rotor diameter, used to compute setbacks from
            rotor-diameter multiplier regulations

        Returns
        -------
        setback : float | None
            setback distance in meters, None if the setback "Value Type"
            was not recognized
        """
        tip_height = hub_height + rotor_diameter / 2

        setback_type = cnty_regs['Value Type']
        setback = cnty_regs['Value']
        if setback_type == 'Max-tip Height Multiplier':
            setback *= tip_height
        elif setback_type == 'Rotor-Diameter Multiplier':
            setback *= rotor_diameter
        elif setback_type == 'Hub-height Multiplier':
            setback *= hub_height
        elif setback_type != 'Meters':
            msg = ('Cannot create setback for {}, expecting '
                   '"Max-tip Height Multiplier", '
                   '"Rotor-Diameter Multiplier", '
                   '"Hub-height Multiplier", or '
                   '"Meters", but got {}'
                   .format(cnty_regs['County'], setback_type))
            logger.warning(msg)
            warn(msg)
            setback = None

        return setback

    @staticmethod
    def _parse_features(features_fpath, crs):
        """
        Abstract method to parse features

        Parameters
        ----------
        features_fpath : str
            Path to file containing features to setback from
        crs : str
            Coordinate reference system to convert structures geometries into

        Returns
        -------
        features : geopandas.GeoDataFrame
            Geometries of features to setback from in exclusion coordinate
            system
        """
        features = gpd.read_file(features_fpath)

        return features.to_crs(crs=crs)

    @staticmethod
    def _compute_local_setbacks(features, cnty, setback):
        """
        Compute local features setbacks

        Parameters
        ----------
        features : geopandas.GeoDataFrame
            Features to setback from
        cnty : geopandas.GeoDataFrame
            Wind regulations for a single county
        setback : int
            Setback distance in meters

        Returns
        -------
        setbacks : list
            List of setback geometries
        """
        logger.debug('- Computing setbacks for county FIPS {}'
                     .format(cnty.iloc[0]['FIPS']))
        log_mem(logger)
        mask = features.centroid.within(cnty['geometry'].values[0])
        tmp = features.loc[mask]
        tmp.loc[:, 'geometry'] = tmp.buffer(setback)

        setbacks = [(geom, 1) for geom in tmp['geometry']]

        return setbacks

    def _parse_county_regs(self, regs):
        """
        Parse wind regulations, combine with county geometries from
        exclusions .h5 file. The county geometries are intersected with
        features to compute county specific setbacks.

        Parameters
        ----------
        regs : pandas.DataFrame
            Wind regulations table

        Returns
        -------
        regs: geopandas.GeoDataFrame
            GeoDataFrame with county level wind setback regulations merged
            with county geometries, use for intersecting with setback features
        """
        if 'FIPS' not in regs:
            msg = ('Wind regulations does not have county FIPS! Please add a '
                   '"FIPS" columns with the unique county FIPS values.')
            logger.error(msg)
            raise RuntimeError(msg)

        if 'geometry' not in regs:
            regs['geometry'] = None

        regs = gpd.GeoDataFrame(regs, crs=self.crs, geometry='geometry')
        regs = regs.set_index('FIPS')

        logger.info('Merging county geometries w/ local wind '
                    'regulations')
        with ExclusionLayers(self._excl_fpath) as exc:
            fips = exc['cnty_fips']
            profile = exc.get_layer_profile('cnty_fips')

        tr = profile['transform']

        for p, v in features.shapes(fips, transform=tr):
            v = int(v)
            if v in regs.index:
                regs.at[v, 'geometry'] = shape(p)

        return regs.reset_index()

    def _parse_regs(self, regs_fpath):
        """
        Parse wind regulations

        Parameters
        ----------
        regs_fpath : str
            Path to wind regulations .csv file

        Returns
        -------
        regs: geopandas.GeoDataFrame
            GeoDataFrame with county level wind setback regulations merged
            with county geometries, use for intersecting with setback features
        """
        try:
            regs = parse_table(regs_fpath)
            regs = self._parse_county_regs(regs)
            log_mem(logger)

            out_path = regs_fpath.split('.')[0] + '.gpkg'
            logger.debug('Saving wind regulations with county geometries as: '
                         '{}'.format(out_path))
            regs.to_file(out_path, driver='GPKG')
        except ValueError:
            regs = gpd.read_file(regs_fpath)

        fips_check = regs['geometry'].isnull()
        if fips_check.any():
            msg = ('The following county FIPS were requested in the '
                   'wind regulations but were not availble in the '
                   'Exclusions "cnty_fips" layer:\n{}'
                   .format(regs.loc[fips_check, 'FIPS']))
            logger.error(msg)
            raise RuntimeError(msg)

        return regs.to_crs(crs=self.crs)

    def _preflight_check(self, regs_fpath, multiplier):
        """
        Run preflight checks on WindSetBack inputs:
        1) Ensure either a wind regulations .csv is provided, or
           a setback multiplier
        2) Ensure wind regulations has county FIPS, map regulations to county
           geometries from exclusions .h5 file
        3) Ensure multiplier is a valid entry, either a float or one of
           {'high': 3, 'moderate': 1.1}

        Parameters
        ----------
        regs_fpath : str | None
            Path to wind regulations .csv file, if None create global
            setbacks
        multiplier : int | float | str | None
            setback multiplier to use if wind regulations are not supplied,
            if str, must one of {'high': 3, 'moderate': 1.1}

        Returns
        -------
        regs: geopandas.GeoDataFrame | None
            GeoDataFrame with county level wind setback regulations merged
            with county geometries, use for intersecting with setback features
        Multiplier : float | None
            Generic setbacks multiplier
        """
        if regs_fpath:
            if multiplier:
                msg = ('A wind regulation .csv file was also provided and '
                       'will be used to determine setback multipliers!')
                logger.warning(msg)
                warn(msg)

            multiplier = None
            regs = self._parse_regs(regs_fpath)
            logger.debug('Computing setbacks using regulations provided in: {}'
                         .format(regs_fpath))
        elif multiplier:
            regs = None
            if isinstance(multiplier, str):
                multiplier = self.MULTIPLIERS[multiplier]

            logger.debug('Computing setbacks using generic Max-tip Height '
                         'Multiplier of {}'.format(multiplier))
        else:
            msg = ('Computing setbacks requires either a wind regulations '
                   '.csv file or a generic multiplier!')
            logger.error(msg)
            raise RuntimeError(msg)

        return regs, multiplier

    def _check_regs(self, features_fpath):  # pylint: disable=unused-argument
        """
        Reduce regs to state corresponding to features_fpath if needed

        Parameters
        ----------
        features_fpath : str
            Path to shape file with features to compute setbacks from

        Returns
        -------
        regs : geopands.GeoDataFrame | None
            Wind Regulations
        """
        logger.debug('Computing setbacks for wind regulations in {} counties'
                     .format(len(self.wind_regs)))

        return self.wind_regs

    def _rasterize_setbacks(self, shapes):
        """
        Convert setbacks geometries into exclusions array

        Parameters
        ----------
        setbacks : list
            List of (geometry, 1) pairs to rasterize. Each geometry is a
            feature buffered by the desired setback distance in meters

        Returns
        -------
        arr : ndarray
            Rasterized array of setbacks
        """
        logger.debug('Generating setbacks exclusion array of shape {}'
                     .format(self.arr_shape))
        log_mem(logger)
        arr = np.zeros(self.arr_shape[1:], dtype='uint8')
        features.rasterize(shapes=shapes,
                           out=arr,
                           out_shape=self.arr_shape[1:],
                           fill=0,
                           transform=self.profile['transform'])

        return arr

    def _write_setbacks(self, geotiff, setbacks, replace=False):
        """
        Write setbacks to geotiff, replace if requested

        Parameters
        ----------
        geotiff : str
            Path to geotiff file to save setbacks too
        setbacks : ndarray
            Rasterized array of setbacks
        replace : bool, optional
            Flag to replace local layer data with arr if layer already
            exists in the exlcusion .h5 file, by default False
        """
        if os.path.exists(geotiff):
            if not replace:
                msg = ('{} already exists. To replace it set "replace=True"'
                       .format(geotiff))
                logger.error(msg)
                raise IOError(msg)
            else:
                msg = ('{} already exists and will be replaced!'
                       .format(geotiff))
                logger.warning(msg)
                warn(msg)

        ExclusionsConverter._write_geotiff(geotiff, self.profile, setbacks)

    def compute_local_setbacks(self, features_fpath, max_workers=None):
        """
        Compute local setbacks for all counties either in serial or parallel.

        Parameters
        ----------
        features_fpath : str
            Path to shape file with features to compute setbacks from
        max_workers : int, optional
            Number of workers to use for setback computation, if 1 run in
            serial, if > 1 run in parallel with that many workers, if None
            run in parallel on all available cores, by default None

        Returns
        -------
        setbacks : ndarray
            Raster array of setbacks
        """
        wind_regs = self._check_regs(features_fpath)
        setbacks = []
        features = self._parse_features(features_fpath, self.crs)
        if max_workers is None:
            max_workers = os.cpu_count()

        log_mem(logger)
        if max_workers > 1:
            logger.info('Computing local setbacks in parallel using {} '
                        'workers'.format(max_workers))
            loggers = [__name__, 'reVX']
            with SpawnProcessPool(max_workers=max_workers,
                                  loggers=loggers) as exe:
                futures = []
                for i in range(len(wind_regs)):
                    cnty = wind_regs.iloc[[i]].copy()
                    setback = self._get_setback(cnty.iloc[0], self.hub_height,
                                                self.rotor_diameter)
                    if setback is not None:
                        idx = features.sindex.intersection(cnty.total_bounds)
                        cnty_feats = features.iloc[list(idx)].copy()
                        future = exe.submit(self._compute_local_setbacks,
                                            cnty_feats, cnty, setback)
                        futures.append(future)

                for i, future in enumerate(as_completed(futures)):
                    setbacks.extend(future.result())
                    logger.debug('Computed setbacks for {} of {} counties'
                                 .format((i + 1), len(wind_regs)))
        else:
            logger.info('Computing local setbacks in serial')
            for i in range(len(wind_regs)):
                cnty = wind_regs.iloc[[i]]
                setback = self._get_setback(cnty.iloc[0], self.hub_height,
                                            self.rotor_diameter)
                if setback is not None:
                    idx = features.sindex.intersection(cnty.total_bounds)
                    cnty_feats = features.iloc[list(idx)]
                    setbacks.extend(self._compute_local_setbacks(cnty_feats,
                                                                 cnty,
                                                                 setback))
                    logger.debug('Computed setbacks for {} of {} counties'
                                 .format((i + 1), len(wind_regs)))

        return self._rasterize_setbacks(setbacks)

    def compute_generic_setbacks(self, features_fpath):
        """
        Compute generic setbacks.

        Parameters
        ----------
        features_fpath : str
            Path to shape file with features to compute setbacks from

        Returns
        -------
        setbacks : ndarray
            Raster array of setbacks
        """
        logger.info('Computing generic setbacks')
        features = self._parse_features(features_fpath, self.crs)
        features.loc[:, 'geometry'] = features.buffer(self.generic_setback)
        setbacks = [(geom, 1) for geom in features['geometry']]

        return self._rasterize_setbacks(setbacks)

    def compute_setbacks(self, features_fpath, max_workers=None,
                         geotiff=None, replace=False):
        """
        Compute setbacks for all states either in serial or parallel.
        Existing setbacks are computed if a wind regulations file was supplied
        during class initialization, otherwise generic setbacks are computed

        Parameters
        ----------
        features_fpath : str
            Path to shape file with features to compute setbacks from
        max_workers : int, optional
            Number of workers to use for setback computation, if 1 run in
            serial, if > 1 run in parallel with that many workers, if None
            run in parallel on all available cores, by default None
        geotiff : str, optional
            Path to save geotiff containing rasterized setbacks,
            by default None
        replace : bool, optional
            Flag to replace geotiff if it already exists, by default False

        Returns
        -------
        setbacks : ndarray
            Raster array of setbacks
        """
        if self._regs is not None:
            setbacks = self.compute_local_setbacks(features_fpath,
                                                   max_workers=max_workers)
        else:
            setbacks = self.compute_generic_setbacks(features_fpath)

        if geotiff is not None:
            logger.debug('Writing setbacks to {}'.format(geotiff))
            self._write_setbacks(geotiff, setbacks, replace=replace)

        return setbacks


class StructureWindSetbacks(BaseWindSetbacks):
    """
    Structure Wind setbacks
    """
    @staticmethod
    def _split_state_name(state_name):
        """
        Split state name at capitals to map .geojson files to regulations
        state names

        Parameters
        ----------
        state_name : str
            State name from geojson files paths with out spaces

        Returns
        -------
        str
            State names with spaces added between Capitals (names) to match
            wind regulations state names
        """
        state_name = ' '.join(a for a
                              in re.split(r'([A-Z][a-z]*)', state_name)
                              if a)

        return state_name

    @staticmethod
    def _get_feature_paths(structures_path):
        """
        Find all structures .geojson files in structures dir

        Parameters
        ----------
        structure_path : str
            Path to structures geojson for a single state, or directory
            containing geojsons for all states. Used to identify structures to
            build setbacks from. Files should be by state

        Returns
        -------
        file_paths : list
            List of file paths to all structures .geojson files in
            structures_dir
        """
        if structures_path.endswith('.geojson'):
            file_paths = [structures_path]
        else:
            file_paths = []
            for file in sorted(os.listdir(structures_path)):
                if file.endswith('.geojson'):
                    file_paths.append(os.path.join(structures_path, file))

        return file_paths

    def _parse_regs(self, regs_fpath):
        """
        Parse wind regulations, reduce table to just structures

        Parameters
        ----------
        regs_fpath : str
            Path to wind regulations .csv file

        Returns
        -------
        regs : pandas.DataFrame
            Wind regulations table
        """
        regs = super()._parse_regs(regs_fpath)

        mask = ((regs['Feature Type'] == 'Structures')
                & (regs['Comment'] != 'Occupied Community Buildings'))
        regs = regs.loc[mask]

        return regs

    def _check_regs(self, features_fpath):
        """
        Reduce regs to state corresponding to features_fpath if needed

        Parameters
        ----------
        features_fpath : str
            Path to shape file with features to compute setbacks from

        Returns
        -------
        wind_regs : geopands.GeoDataFrame | None
            Wind Regulations
        """
        state_name = os.path.basename(features_fpath).split('.')[0]
        state = self._split_state_name(state_name)
        wind_regs = self.wind_regs
        mask = wind_regs["State"] == state

        if not mask.any():
            msg = ("There are no local regulations in {}!".format(state))
            logger.error(msg)
            raise RuntimeError(msg)

        wind_regs = wind_regs.loc[mask].reset_index(drop=True)
        logger.debug('Computing setbacks for wind regulations in {} counties'
                     .format(len(wind_regs)))

        return wind_regs

    @classmethod
    def run(cls, excl_fpath, structures_path, out_dir, hub_height,
            rotor_diameter, regs_fpath=None, multiplier=None,
            chunks=(128, 128), max_workers=None, replace=False, hsds=False):
        """
        Compute state's structural setbacks and write them to a geotiff.
        If a wind regulations file is given compute local setbacks, otherwise
        compute generic setbacks using the given multiplier and the turbine
        tip-height.

        Parameters
        ----------
        excl_fpath : str
            Path to .h5 file containing exclusion layers, will also be the
            location of any new setback layers
        structure_path : str
            Path to structures geojson for a single state, or directory
            containing geojsons for all states.
        out_dir : str
            Directory to save setbacks geotiff(s) into
        hub_height : float | int
            Turbine hub height (m), used along with rotor diameter to compute
            blade tip height which is used to determine setback distance
        rotor_diameter : float | int
            Turbine rotor diameter (m), used along with hub height to compute
            blade tip height which is used to determine setback distance
        regs_fpath : str | None, optional
            Path to wind regulations .csv file, if None create generic
            setbacks using max-tip height * "multiplier", by default None
        multiplier : int | float | str | None, optional
            setback multiplier to use if wind regulations are not supplied,
            if str, must a key in {'high': 3, 'moderate': 1.1}, if supplied
            along with regs_fpath, will be ignored, multiplied with
            max-tip height, by default None
        chunks : tuple, optional
            Chunk size to use for setback layers, if None use default chunk
            size in excl_fpath, by default (128, 128)
        max_workers : int, optional
            Number of workers to use for setback computation, if 1 run in
            serial, if > 1 run in parallel with that many workers, if None
            run in parallel on all available cores, by default None
        replace : bool, optional
            Flag to replace geotiff if it already exists, by default False
        hsds : bool, optional
            Boolean flag to use h5pyd to handle .h5 'files' hosted on AWS
            behind HSDS, by default False
        """
        setbacks = cls(excl_fpath, hub_height, rotor_diameter,
                       regs_fpath=regs_fpath, multiplier=multiplier,
                       hsds=hsds, chunks=chunks)

        structures_path = setbacks._get_feature_paths(structures_path)

        for fpath in structures_path:
            geotiff = os.path.basename(fpath).replace('.geojson', '.tif')
            geotiff = os.path.join(out_dir, geotiff)
            if os.path.exists(geotiff) and not replace:
                msg = ('{} already exists, setbacks will not be re-computed '
                       'unless replace=True'.format(geotiff))
                logger.error(msg)
            else:
                logger.info("Computing setbacks from structures in {} and "
                            "saving to {}".format(fpath, geotiff))
                setbacks.compute_setbacks(fpath, geotiff=geotiff,
                                          max_workers=max_workers,
                                          replace=replace)


class RoadWindSetbacks(BaseWindSetbacks):
    """
    Road Wind setbacks
    """
    @staticmethod
    def _parse_features(roads_fpath, crs):
        """
        Load roads from gdb file, convert to exclusions coordinate system

        Parameters
        ----------
        roads_fpath : str
            Path to here streets gdb file for given state
        crs : str
            Coordinate reference system to convert structures geometries into

        Returns
        -------
        roads : geopandas.GeoDataFrame.sindex
            Geometries for roads in gdb file, in exclusion coordinate
            system
        """
        lyr = fiona.listlayers(roads_fpath)[0]
        roads = gpd.read_file(roads_fpath, driver='FileGDB', layer=lyr)

        return roads.to_crs(crs=crs)

    @staticmethod
    def _get_feature_paths(roads_path):
        """
        Find all roads gdb files in roads_dir

        Parameters
        ----------
        roads_path : str
            Path to state here streets gdb file or directory containing
            states gdb files. Used to identify roads to build setbacks from.
            Files should be by state

        Returns
        -------
        file_paths : list
            List of file paths to all roads .gdp files in roads_dir
        """
        if roads_path.endswith('.gdb'):
            file_paths = [roads_path]
        else:
            file_paths = []
            for file in sorted(os.listdir(roads_path)):
                if file.endswith('.gdb') and file.startswith('Streets_USA'):
                    file_paths.append(os.path.join(roads_path, file))

        return file_paths

    def _parse_regs(self, regs_fpath):
        """
        Parse wind regulations, reduce table to just roads

        Parameters
        ----------
        regs_fpath : str
            Path to wind regulations .csv file

        Returns
        -------
        regs : pandas.DataFrame
            Wind regulations table
        """
        regs = super()._parse_regs(regs_fpath)

        mask = regs['Feature Type'].isin(['Roads', 'Highways', 'Highways 111'])
        regs = regs.loc[mask]

        return regs

    def _check_regs(self, features_fpath):
        """
        Reduce regs to state corresponding to features_fpath if needed

        Parameters
        ----------
        features_fpath : str
            Path to shape file with features to compute setbacks from

        Returns
        -------
        wind_regs : geopands.GeoDataFrame | None
            Wind Regulations
        """
        state = features_fpath.split('.')[0].split('_')[-1]
        wind_regs = self.wind_regs
        mask = wind_regs['Abbr'] == state

        if not mask.any():
            msg = ("There are no local regulations in {}!".format(state))
            logger.error(msg)
            raise RuntimeError(msg)

        wind_regs = wind_regs.loc[mask].reset_index(drop=True)
        logger.debug('Computing setbacks for wind regulations in {} counties'
                     .format(len(wind_regs)))

        return wind_regs

    @classmethod
    def run(cls, excl_fpath, roads_path, out_dir, hub_height,
            rotor_diameter, regs_fpath=None, multiplier=None,
            chunks=(128, 128), max_workers=None, replace=False, hsds=False):
        """
        Compute state's road setbacks and write them to a geotiff.
        If a wind regulations file is given compute local setbacks, otherwise
        compute generic setbacks using the given multiplier and the turbine
        tip-height.

        Parameters
        ----------
        excl_fpath : str
            Path to .h5 file containing exclusion layers, will also be the
            location of any new setback layers
        road_path : str
            Path to state here streets gdb file or directory containing
            states gdb files.
        out_dir : str
            Directory to save setbacks geotiff(s) into
        hub_height : float | int
            Turbine hub height (m), used along with rotor diameter to compute
            blade tip height which is used to determine setback distance
        rotor_diameter : float | int
            Turbine rotor diameter (m), used along with hub height to compute
            blade tip height which is used to determine setback distance
        regs_fpath : str | None, optional
            Path to wind regulations .csv file, if None create generic
            setbacks using max-tip height * "multiplier", by default None
        multiplier : int | float | str | None, optional
            setback multiplier to use if wind regulations are not supplied,
            if str, must a key in {'high': 3, 'moderate': 1.1}, if supplied
            along with regs_fpath, will be ignored, multiplied with
            max-tip height, by default None
        chunks : tuple, optional
            Chunk size to use for setback layers, if None use default chunk
            size in excl_fpath, by default (128, 128)
        max_workers : int, optional
            Number of workers to use for setback computation, if 1 run in
            serial, if > 1 run in parallel with that many workers, if None
            run in parallel on all available cores, by default None
        replace : bool, optional
            Flag to replace geotiff if it already exists, by default False
        hsds : bool, optional
            Boolean flag to use h5pyd to handle .h5 'files' hosted on AWS
            behind HSDS, by default False
        """
        setbacks = cls(excl_fpath, hub_height, rotor_diameter,
                       regs_fpath=regs_fpath, multiplier=multiplier,
                       hsds=hsds, chunks=chunks)

        roads_path = setbacks._get_feature_paths(roads_path)
        for fpath in roads_path:
            geotiff = os.path.basename(fpath).replace('.gdb', '.tif')
            geotiff = os.path.join(out_dir, geotiff)
            if os.path.exists(geotiff) and not replace:
                msg = ('{} already exists, setbacks will not be re-computed '
                       'unless replace=True'.format(geotiff))
                logger.error(msg)
            else:
                logger.info("Computing setbacks from roads in {} and saving "
                            "to {}".format(fpath, geotiff))
                setbacks.compute_setbacks(fpath, geotiff=geotiff,
                                          max_workers=max_workers,
                                          replace=replace)


class TransmissionWindSetbacks(BaseWindSetbacks):
    """
    Transmission Wind setbacks, computed against a single set of transmission
    features instead of against state level features
    """
    @staticmethod
    def _compute_local_setbacks(features, cnty, setback):
        """
        Compute local county setbacks

        Parameters
        ----------
        features : geopandas.GeoDataFrame
            Features to setback from
        cnty : geopandas.GeoDataFrame
            Wind regulations for a single county
        setback : int
            Setback distance in meters

        Returns
        -------
        setbacks : list
            List of setback geometries
        """
        logger.debug('- Computing setbacks for county FIPS {}'
                     .format(cnty.iloc[0]['FIPS']))
        log_mem(logger)
        tmp = gpd.clip(features, cnty)
        tmp = tmp[~tmp.is_empty]

        # Buffer setback
        tmp.loc[:, 'geometry'] = tmp.buffer(setback)

        setbacks = [(geom, 1) for geom in tmp['geometry']]

        return setbacks

    @staticmethod
    def _get_feature_paths(features_path):
        """
        Ensure features path is valid

        Parameters
        ----------
        features_path : str
            Path to features file

        Returns
        -------
        features_path : list
            Features path as a list
        """
        if not os.path.exists(features_path):
            msg = '{} is not a valid file path!'.format(features_path)
            logger.error(msg)
            raise FileNotFoundError(msg)

        return [features_path]

    def _parse_regs(self, regs_fpath):
        """
        Parse wind regulations, reduce table to just transmission

        Parameters
        ----------
        regs_fpath : str
            Path to wind regulations .csv file

        Returns
        -------
        regs : pandas.DataFrame
            Wind regulations table
        """
        regs = super()._parse_regs(regs_fpath)

        mask = regs['Feature Type'] == 'Transmission'
        regs = regs.loc[mask]

        return regs

    @classmethod
    def run(cls, excl_fpath, features_fpath, out_dir, hub_height,
            rotor_diameter, regs_fpath=None, multiplier=None,
            chunks=(128, 128), max_workers=None, replace=False, hsds=False):
        """
        Compute setbacks from given features and write them to a geotiff.
        If a wind regulations file is given compute local setbacks, otherwise
        compute generic setbacks using the given multiplier and the turbine
        tip-height.
        Parameters
        ----------
        excl_fpath : str
            Path to .h5 file containing exclusion layers, will also be the
            location of any new setback layers
        features_fpath : str
            Path to shape file with transmission or rail features to compute
            setbacks from
        out_dir : str
            Directory to save geotiff containing rasterized setbacks into
        hub_height : float | int
            Turbine hub height (m), used along with rotor diameter to compute
            blade tip height which is used to determine setback distance
        rotor_diameter : float | int
            Turbine rotor diameter (m), used along with hub height to compute
            blade tip height which is used to determine setback distance
        regs_fpath : str | None, optional
            Path to wind regulations .csv file, if None create generic
            setbacks using max-tip height * "multiplier", by default None
        multiplier : int | float | str | None, optional
            setback multiplier to use if wind regulations are not supplied,
            if str, must a key in {'high': 3, 'moderate': 1.1}, if supplied
            along with regs_fpath, will be ignored, multiplied with
            max-tip height, by default None
        chunks : tuple, optional
            Chunk size to use for setback layers, if None use default chunk
            size in excl_fpath, by default (128, 128)
        max_workers : int, optional
            Number of workers to use for setback computation, if 1 run in
            serial, if > 1 run in parallel with that many workers, if None
            run in parallel on all available cores, by default None
        replace : bool, optional
            Flag to replace geotiff if it already exists, by default False
        hsds : bool, optional
            Boolean flag to use h5pyd to handle .h5 'files' hosted on AWS
            behind HSDS, by default False
        """
        geotiff = os.path.basename(features_fpath).split('.')[0]
        geotiff += '.tif'
        geotiff = os.path.join(out_dir, geotiff)
        if os.path.exists(geotiff) and not replace:
            msg = ('{} already exists, setbacks will not be re-computed '
                   'unless replace=True'.format(geotiff))
            logger.error(msg)
        else:
            setbacks = cls(excl_fpath, hub_height, rotor_diameter,
                           regs_fpath=regs_fpath, multiplier=multiplier,
                           hsds=hsds, chunks=chunks)

            logger.info("Computing setbacks from {} and saving "
                        "to {}".format(features_fpath, geotiff))
            setbacks.compute_setbacks(features_fpath, geotiff=geotiff,
                                      max_workers=max_workers,
                                      replace=replace)


class RailWindSetbacks(TransmissionWindSetbacks):
    """
    Rail Wind setbacks, computed against a single set of railroad features,
    instead of state level features, uses the same approach as
    TransmissionWindSetbacks
    """
    def _parse_regs(self, regs_fpath):
        """
        Parse wind regulations, reduce table to just rail

        Parameters
        ----------
        regs_fpath : str
            Path to wind regulations .csv file

        Returns
        -------
        regs : pandas.DataFrame
            Wind regulations table
        """
        # pylint: disable=bad-super-call
        regs = super(TransmissionWindSetbacks, self)._parse_regs(regs_fpath)

        mask = regs['Feature Type'] == 'Railroads'
        regs = regs.loc[mask]

        return regs
