def _get_historical_forecastable_time_index(
    model,
    series: TimeSeries,
    forecast_horizon: int,
    overlap_end: bool,
    past_covariates: Optional[TimeSeries] = None,
    future_covariates: Optional[TimeSeries] = None,
    is_training: Optional[bool] = False,
    reduce_to_bounds: bool = False,
) -> Union[
    pd.DatetimeIndex,
    pd.RangeIndex,
    tuple[int, int],
    tuple[pd.Timestamp, pd.Timestamp],
    None,
]:
    """
    Private function that returns the largest time_index representing the subset of each timestamps
    for which historical forecasts can be made, given the model's properties, the training series
    and the covariates.
        - If ``None`` is returned, there is no point where a forecast can be made.

        - If ``is_training=False``, it returns the time_index subset of predictable timestamps.

        - If ``is_training=True``, it returns the time_index subset of trainable timestamps. A trainable
        timestamp is a timestamp that has a training sample of length at least ``self.training_sample_length``
            preceding it.

    Additionally, it accounts for auto-regression (forecast_horizon > model.output_chunk_length , and overlap_end.
        - In case of auto-regression, we have to step back the last possible predictable/trainable time step if the
          covariates are too short
        - In case overlap_end=False, the latest possible predictable/trainable time step is shifted back if a
          prediction starting from that point would go beyond the end of `series`.


    Parameters
    ----------
    series
        A target series.
    forecast_horizon
        The forecast horizon for the predictions.
    overlap_end
        Whether the returned forecasts can go beyond the series' end or not.
    past_covariates
        Optionally, a past covariates.
    future_covariates
        Optionally, a future covariates.
    is_training
        Whether the returned time_index should be taking into account the training.
    reduce_to_bounds
        Whether to only return the minimum and maximum historical forecastable index

    Returns
    -------
    Union[pd.DatetimeIndex, pd.RangeIndex, tuple[int, int], tuple[pd.Timestamp, pd.Timestamp], None]
        The longest time_index that can be used for historical forecasting, either as a range or a tuple.

    Examples
    --------
    >>> model = LinearRegressionModel(lags=3, output_chunk_length=2)
    >>> model.fit(train_series)
    >>> series = TimeSeries.from_times_and_values(pd.date_range('2000-01-01', '2000-01-10'), np.arange(10))
    >>> model._get_historical_forecastable_time_index(series=series, is_training=False, forecast_horizon=1)
    DatetimeIndex(
            ['2000-01-04', '2000-01-05', '2000-01-06', '2000-01-07', '2000-01-08', '2000-01-09', '2000-01-10'],
            dtype='datetime64[ns]', freq='D'
    )
    >>> model._get_historical_forecastable_time_index(series=series, is_training=True)
    DatetimeIndex(['2000-01-06', '2000-01-08', '2000-01-09', '2000-01-10'], dtype='datetime64[ns]', freq='D')
    >>> model = NBEATSModel(input_chunk_length=3, output_chunk_length=3)
    >>> model.fit(train_series, train_past_covariates)
    >>> series = TimeSeries.from_times_and_values(pd.date_range('2000-10-01', '2000-10-09'), np.arange(8))
    >>> past_covariates = TimeSeries.from_times_and_values(
    >>>     pd.date_range('2000-10-03', '2000-10-20'),
    >>>     np.arange(18)
    >>> )
    >>> model._get_historical_forecastable_time_index(
    >>>     series=series,
    >>>     past_covariates=past_covariates,
    >>>     is_training=False,
    >>>     forecast_horizon=1,
    >>> )
    DatetimeIndex(['2000-10-06', '2000-10-07', '2000-10-08', '2000-10-09'], dtype='datetime64[ns]', freq='D')
    >>>  # Only one point is trainable; it corresponds to the first point after we reach a common subset of
    >>> # timestamps of training_sample_length length.
    >>> model._get_historical_forecastable_time_index(
    >>>     series=series,
    >>>     past_covariates=past_covariates,
    >>>     is_training=True,
    >>> )
    DatetimeIndex(['2000-10-09'], dtype='datetime64[ns]', freq='D')
    """

    (
        min_target_lag,
        max_target_lag,
        min_past_cov_lag,
        max_past_cov_lag,
        min_future_cov_lag,
        max_future_cov_lag,
        output_chunk_shift,
        max_target_lag_train,
    ) = model.extreme_lags

    # max_target_lag < 0 are local models which can predict for n (horizon) -> infinity (no auto-regression)
    is_autoregression = (
        max_target_lag >= 0
        and forecast_horizon > max_target_lag - output_chunk_shift + 1
    )

    if min_target_lag is None:
        min_target_lag = 0

    if is_training and max_target_lag_train is not None:
        # the output lag/window can be different for train and predict modes
        output_lag = max_target_lag_train
    else:
        output_lag = max_target_lag

    # longest possible time index for target
    if is_training:
        start = (
            series.start_time()
            + (output_lag - output_chunk_shift - min_target_lag + 1) * series.freq
        )
    else:
        start = series.start_time() - min_target_lag * series.freq
    end = series.end_time() + 1 * series.freq

    intersect_ = (start, end)

    # longest possible time index for past covariates
    if (min_past_cov_lag is not None) and (past_covariates is not None):
        if is_training:
            start_pc = (
                past_covariates.start_time()
                + (output_lag - output_chunk_shift - min_past_cov_lag + 1)
                * past_covariates.freq
            )
        else:
            start_pc = (
                past_covariates.start_time() - min_past_cov_lag * past_covariates.freq
            )

        shift_pc_end = max_past_cov_lag
        if is_autoregression:
            # we step back in case of auto-regression
            shift_pc_end += forecast_horizon - (max_target_lag - output_chunk_shift + 1)
        end_pc = past_covariates.end_time() - shift_pc_end * past_covariates.freq

        intersect_ = (
            max([intersect_[0], start_pc]),
            min([intersect_[1], end_pc]),
        )

    # longest possible time index for future covariates
    if (min_future_cov_lag is not None) and (future_covariates is not None):
        if is_training:
            start_fc = (
                future_covariates.start_time()
                + (output_lag - output_chunk_shift - min_future_cov_lag + 1)
                * future_covariates.freq
            )
        else:
            start_fc = (
                future_covariates.start_time()
                - min_future_cov_lag * future_covariates.freq
            )

        shift_fc_end = max_future_cov_lag
        if is_autoregression:
            # we step back in case of auto-regression
            shift_fc_end += forecast_horizon - (max_target_lag - output_chunk_shift + 1)
        end_fc = future_covariates.end_time() - shift_fc_end * future_covariates.freq

        intersect_ = (
            max([intersect_[0], start_fc]),
            min([intersect_[1], end_fc]),
        )

    # overlap_end = False -> predictions must not go beyond end of target series
    if (
        not overlap_end
        and intersect_[1] + (forecast_horizon + output_chunk_shift - 1) * series.freq
        > series.end_time()
    ):
        intersect_ = (
            intersect_[0],
            end - (forecast_horizon + output_chunk_shift) * series.freq,
        )

    # end comes before the start
    if intersect_[1] < intersect_[0]:
        return None

    # if SKLearnModel is not multi_models, it looks further in the past
    is_multi_models = getattr(model, "multi_models", None)
    if is_multi_models is not None and not is_multi_models:
        intersect_ = (
            intersect_[0] + (model.output_chunk_length - 1) * series.freq,
            intersect_[1],
        )

    # generate an index
    if not reduce_to_bounds:
        intersect_ = generate_index(
            start=intersect_[0], end=intersect_[1], freq=series.freq
        )

    return intersect_ if len(intersect_) > 0 else None

    Private function that returns the largest time_index representing the subset of each timestamps
    for which historical forecasts can be made, given the model's properties, the training series
    and the covariates.
        - If ``None`` is returned, there is no point where a forecast can be made.

        - If ``is_training=False``, it returns the time_index subset of predictable timestamps.

        - If ``is_training=True``, it returns the time_index subset of trainable timestamps. A trainable
        timestamp is a timestamp that has a training sample of length at least ``self.training_sample_length``
            preceding it.

    Additionally, it accounts for auto-regression (forecast_horizon > model.output_chunk_length , and overlap_end.
        - In case of auto-regression, we have to step back the last possible predictable/trainable time step if the
          covariates are too short
        - In case overlap_end=False, the latest possible predictable/trainable time step is shifted back if a
          prediction starting from that point would go beyond the end of `series`.


    Parameters
    ----------
    series
        A target series.
    forecast_horizon
        The forecast horizon for the predictions.
    overlap_end
        Whether the returned forecasts can go beyond the series' end or not.
    past_covariates
        Optionally, a past covariates.
    future_covariates
        Optionally, a future covariates.
    is_training
        Whether the returned time_index should be taking into account the training.
    reduce_to_bounds
        Whether to only return the minimum and maximum historical forecastable index

    Returns
    -------
    Union[pd.DatetimeIndex, pd.RangeIndex, tuple[int, int], tuple[pd.Timestamp, pd.Timestamp], None]
        The longest time_index that can be used for historical forecasting, either as a range or a tuple.

    Examples
    --------
    >>> model = LinearRegressionModel(lags=3, output_chunk_length=2)
    >>> model.fit(train_series)
    >>> series = TimeSeries.from_times_and_values(pd.date_range('2000-01-01', '2000-01-10'), np.arange(10))
    >>> model._get_historical_forecastable_time_index(series=series, is_training=False, forecast_horizon=1)
    DatetimeIndex(
            ['2000-01-04', '2000-01-05', '2000-01-06', '2000-01-07', '2000-01-08', '2000-01-09', '2000-01-10'],
            dtype='datetime64[ns]', freq='D'
    )
    >>> model._get_historical_forecastable_time_index(series=series, is_training=True)
    DatetimeIndex(['2000-01-06', '2000-01-08', '2000-01-09', '2000-01-10'], dtype='datetime64[ns]', freq='D')
    >>> model = NBEATSModel(input_chunk_length=3, output_chunk_length=3)
    >>> model.fit(train_series, train_past_covariates)
    >>> series = TimeSeries.from_times_and_values(pd.date_range('2000-10-01', '2000-10-09'), np.arange(8))
    >>> past_covariates = TimeSeries.from_times_and_values(
    >>>     pd.date_range('2000-10-03', '2000-10-20'),
    >>>     np.arange(18)
    >>> )
    >>> model._get_historical_forecastable_time_index(
    >>>     series=series,
    >>>     past_covariates=past_covariates,
    >>>     is_training=False,
    >>>     forecast_horizon=1,
    >>> )
    DatetimeIndex(['2000-10-06', '2000-10-07', '2000-10-08', '2000-10-09'], dtype='datetime64[ns]', freq='D')
    >>>  # Only one point is trainable; it corresponds to the first point after we reach a common subset of
    >>> # timestamps of training_sample_length length.
    >>> model._get_historical_forecastable_time_index(
    >>>     series=series,
    >>>     past_covariates=past_covariates,
    >>>     is_training=True,
    >>> )
    DatetimeIndex(['2000-10-09'], dtype='datetime64[ns]', freq='D')
    
darts/utils/historical_forecasts/utils.py