%%
%% %CopyrightBegin%
%%
%% SPDX-License-Identifier: Apache-2.0
%%
%% Copyright Ericsson AB 2021-2026. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%
%% %CopyrightEnd%
%%
-module(erl_features).
-moduledoc """
This module contains functions for supporting features that can be
enabled/disabled in Erlang.

It should be considered as mostly for internal use, although there are some
functions that might be useful when writing tools.
""".
-moduledoc(#{since => "OTP 25.0"}).

-compile([{nowarn_possibly_unsafe_function, {erlang, list_to_atom, 1}},
          {nowarn_possibly_unsafe_function, {erlang, binary_to_term, 1}}]).

-export([all/0,
         configurable/0,
         info/1,
         short/1,
         long/1,
         enabled/0,
         keywords/0,
         keywords/1,
         init_parse_state/2,
         update_parse_state/4,
         used/1,
         format_error/1,
         format_error/2]).

%% temporary export for bootstrapping build
-export([keyword_fun/2, keyword_fun/4]).

-type type() :: 'extension' | 'backwards_incompatible_change'.
-type status() :: 'experimental'
                  | 'approved'
                  | 'permanent'
                  | 'rejected'.
-type release() :: non_neg_integer().
-type feature() :: atom().
-type error() :: {?MODULE,
                  {'invalid_features', [atom()]}
                  | {'incorrect_features', [atom()]}
                  | {'not_configurable', [atom()]}}.

-define(VALID_FEATURE(Feature),
        (case is_valid(Feature) of
             false ->
                 error(invalid_feature, [Feature],
                       [{error_info,
                         #{module => ?MODULE,
                           cause => #{1 => "unknown feature"}}}]);
             true -> ok
         end)).

%% Specification about currently known features.
feature_specs() ->
    #{maybe_expr =>
          #{short => "Value based error handling (EEP49)",
            description =>
                "Implementation of the maybe expression proposed in EEP49 -- "
            "Value based error handling.",
            status => approved,
            experimental => 25,
            approved => 27,
            keywords => ['maybe', 'else'],
            type => extension},
      compr_assign =>
          #{short => "Assignment in comprehensions (EEP77)",
            description =>
                "Implementation of '=' assignments in comprehensions proposed in EEP77.",
            status => experimental,
            experimental => 29,
            keywords => [],
            type => extension}}.

%% Return all currently known features.
-doc """
Return a list of all known features. This list will include features that have
been removed (status `rejected`) and features that are no longer configurable
(status `permanent`).
""".
-doc(#{since => <<"OTP 25.0">>}).
-spec all() -> [feature()].
all() ->
    Map = case persistent_term:get({?MODULE, feature_specs}, none) of
              none -> init_specs();
              M -> M
          end,
    lists:sort(maps:keys(Map)).

approved() ->
    [Ftr || Ftr <- all(),
            maps:get(status, info(Ftr)) =:= approved].

-doc """
Return a list of all configurable features, that is, features with status
`experimental` or `approved`. These are the features that can be enabled or
disabled.
""".
-doc(#{since => <<"OTP 25.1">>}).
-spec configurable() -> [feature()].
configurable() ->
    [Ftr || Ftr <- all(),
            lists:member(maps:get(status, info(Ftr)),
                         [experimental, approved])].

is_valid(Ftr) ->
    lists:member(Ftr, all()).

is_configurable(Ftr) ->
    lists:member(Ftr, configurable()).

-doc false.
-spec short(feature()) -> iolist() | no_return().
short(Feature) ->
    #{short := Short,
      status := Status} = Info = info(Feature),
    #{Status := Release} = Info,
    io_lib:format("~-40s ~-12s (~p)", [Short, Status, Release]).

-doc false.
-spec long(feature()) -> iolist() | no_return().
long(Feature) ->
    #{short := Short,
      description := Description,
      status := Status,
      keywords := Keywords,
      type := Type} = Info = info(Feature),
    StatusFmt = "  ~-10s ~-12s (~p)\n",
    History = [io_lib:format(StatusFmt, [T, S, R])
               || {T, S, R} <- history(Status, Info)],
    KeywordsStrs =
        if Keywords == [] -> "";
           true ->
                io_lib:format("  ~-10s ~p\n", ["Keywords", Keywords])
        end,
    Lines = [{"~s - ~s\n", [Feature, Short]},
             {"  ~-10s ~s\n", ["Type", Type]},
             {"~s", [History]},
             {"~s", [KeywordsStrs]},
             {"\n~s\n", [nqTeX(Description)]}],
    [io_lib:format(FStr, Args) || {FStr, Args} <- Lines].

history(Current, Info) ->
    G = fun(Key, S) ->
                case maps:find(Key, Info) of
                    error -> [];
                    {ok, R} -> [{S, Key, R}]
                end
        end,
    F = fun(Key) -> G(Key, "") end,
    History =
        case Current of
            experimental -> [];
            rejected -> F(experimental);
            approved -> F(experimental);
            permanent -> F(approved) ++ F(experimental)
        end,
    G(Current, "Status") ++ History.

%% Dead simple line breaking for better presentation.
nqTeX(String) ->
    Words = string:tokens(String, " "),
    WithLens = lists:map(fun(W) -> {W, length(W)} end, Words),
    adjust(WithLens).

adjust(WLs) ->
    adjust(0, WLs, []).

adjust(_, [], Ws) ->
    lists:reverse(tl(Ws));
adjust(Col, [{W, L}| WLs], Ws) ->
    case Col + L > 72 of
        true ->
            lists:reverse(["\n"| tl(Ws)])
                ++ adjust(L+1, WLs, [" ", W]);
        false ->
            adjust(Col + L + 1, WLs, [" ", W| Ws])
    end.


-doc "Return a map containing information about the given feature.".
-doc(#{since => <<"OTP 25.0">>}).
-spec info(feature()) -> FeatureInfoMap | no_return()
              when
      Description :: string(),
      FeatureInfoMap ::
        #{description := Description,
          short := Description,
          type := type(),
          keywords := [atom()],
          status := status(),
          experimental => release(),
          approved => release(),
          permanent => release(),
          rejected => release()
         }.
info(Feature) ->
    ?VALID_FEATURE(Feature),

    Map = persistent_term:get({?MODULE, feature_specs}),
    maps:get(Feature, Map).

%% New keywords introduced by a feature.
-doc false.
-spec keywords(feature()) -> [atom()] | no_return().
keywords(Ftr) ->
    ?VALID_FEATURE(Ftr),

    #{keywords := Keywords} = info(Ftr),
    Keywords.

%% Internal - Ftr is valid
keywords(Ftr, Map) ->
    maps:get(keywords, maps:get(Ftr, Map)).

%% Utilities

%% temporary aliases for bootstrapping build

-doc false.
-spec keyword_fun([term()], fun((atom()) -> boolean())) ->
          {'ok', {[feature()], fun((atom()) -> boolean())}}
              | {'error', error()}.
keyword_fun(Opts, KeywordFun) ->
    init_parse_state(Opts, KeywordFun).

-doc false.
-spec keyword_fun('enable' | 'disable', feature(), [feature()],
                  fun((atom()) -> boolean())) ->
          {'ok', {[feature()], fun((atom()) -> boolean())}}
              | {'error', error()}.
keyword_fun(Ind, Feature, Ftrs, KeywordFun) ->
    update_parse_state(Ind, Feature, Ftrs, KeywordFun).

%% Handles `{feature, F, State}` options and returns the list of enabled
%% features together with a keywords function, as the initial state for
%% parsing, starting from the set of statically enabled features. This
%% is then modified (in epp) using `update_parse_state/4` when a
%% declaration `-feature(...)` is encountered.
-doc false.
-spec init_parse_state([term()], fun((atom()) -> boolean())) ->
          {'ok', {[feature()], fun((atom()) -> boolean())}}
              | {'error', error()}.
init_parse_state(Opts, KeywordFun) ->
    %% Get items enabling or disabling features, preserving order.
    IsFtr = fun({feature, _, enable}) -> true;
               ({feature, _, disable}) -> true;
               (_) -> false
            end,
    FeatureOps = lists:filter(IsFtr, Opts),
    {AddFeatures, DelFeatures, RawFtrs} = collect_features(FeatureOps, enabled()),
    case configurable_features(RawFtrs) of
        ok ->
            {ok, Fun} = add_features_fun(AddFeatures, KeywordFun),
            {ok, FunX} = remove_features_fun(DelFeatures, Fun),
            {ok, {AddFeatures -- DelFeatures, FunX}};
        {error, _} = Error ->
            Error
    end.

%% Modifies the given keyword fun to accept/deny keywords when a feature
%% is turned on or off. Used by epp when scanning feature declarations.
-doc false.
-spec update_parse_state('enable' | 'disable', feature(), [feature()],
                  fun((atom()) -> boolean())) ->
          {'ok', {[feature()], fun((atom()) -> boolean())}}
              | {'error', error()}.
update_parse_state(Ind, Feature, Ftrs, KeywordFun) ->
    case is_configurable(Feature) of
        true ->
            case Ind of
                enable ->
                    NewFtrs = case lists:member(Feature, Ftrs) of
                                  true -> Ftrs;
                                  false -> [Feature | Ftrs]
                              end,
                    {ok, {NewFtrs,
                          add_feature_fun(Feature, KeywordFun)}};
                disable ->
                    {ok, {Ftrs -- [Feature],
                          remove_feature_fun(Feature, KeywordFun)}}
            end;
        false ->
            Error =
                case is_valid(Feature) of
                    true -> not_configurable;
                    false -> invalid_features
                end,
            {error, {?MODULE, {Error, [Feature]}}}
    end.

add_feature_fun(Feature, F) ->
    Words = keywords(Feature),
    fun(Word) ->
            lists:member(Word, Words)
                orelse F(Word)
    end.

remove_feature_fun(Feature, F) ->
    Words = keywords(Feature),
    fun(Word) ->
            case lists:member(Word, Words) of
                true -> false;
                false -> F(Word)
            end
    end.

-spec add_features_fun([feature()], fun((atom()) -> boolean())) ->
          {'ok', fun((atom()) -> boolean())}.
add_features_fun(Features, F) ->
    {ok, lists:foldl(fun add_feature_fun/2, F, Features)}.

-spec remove_features_fun([feature()], fun((atom()) -> boolean())) ->
          {'ok', fun((atom()) -> boolean())}.
remove_features_fun(Features, F) ->
    {ok, lists:foldl(fun remove_feature_fun/2, F, Features)}.

configurable_features(Features) ->
    case lists:all(fun is_configurable/1, Features) of
        true ->
            ok;
        false ->
            feature_error(Features)
    end.

feature_error(Features) ->
    IsInvalid = fun(Ftr) -> not is_valid(Ftr) end,
    IsNonConfig = fun(Ftr) ->
                          is_valid(Ftr)
                              andalso
                                (not is_configurable(Ftr))
                  end,
    Invalid = lists:filter(IsInvalid, Features),
    NonConfig = lists:filter(IsNonConfig, Features),
    {Error, Culprits} =
        case {Invalid, NonConfig} of
            {[], NC} -> {not_configurable, NC};
            {NV, []} -> {invalid_features, NV};
            {NV, NC} -> {incorrect_features, NV ++ NC}
        end,
    {error, {?MODULE, {Error, Culprits}}}.

-doc false.
-spec format_error(Reason, StackTrace) -> ErrorDescription
              when Reason :: term(),
                   StackTrace :: erlang:stacktrace(),
                   ArgumentPosition :: pos_integer(),
                   ErrorDescription :: #{ArgumentPosition => unicode:chardata(),
                                         general => unicode:chardata(),
                                         reason => unicode:chardata()}.
format_error(Reason, [{_M, _F, _Args, Info}| _St]) ->
    ErrorInfo = proplists:get_value(error_info, Info, #{}),
    ErrorMap = maps:get(cause, ErrorInfo),
    ErrorMap#{reason => io_lib:format("~p: ~p", [?MODULE, Reason])}.

-doc false.
-spec format_error(Reason) -> iolist()
              when Reason :: term().
format_error({Error, Features}) ->
    Fmt = fun F([Ftr]) -> io_lib:fwrite("'~p'", [Ftr]);
              F([Ftr1, Ftr2]) ->
                  io_lib:fwrite("'~p' and '~p'", [Ftr1, Ftr2]);
              F([Ftr| Ftrs]) ->
                  io_lib:fwrite("'~p', ~s", [Ftr, F(Ftrs)])
          end,
    FmtStr =
        case {Error, Features} of
            {invalid_features, [_]} ->
                "the feature ~s does not exist.";
            {invalid_features, _} ->
                "the features ~s do not exist.";
            {not_configurable, [_]} ->
                "the feature ~s is not configurable.";
            {not_configurable, _} ->
                "the features ~s are not configurable.";
            {incorrect_features, _} ->
                "the features ~s do not exist or are not configurable."
        end,

    io_lib:fwrite(FmtStr, [Fmt(Features)]).

%% Hold the state of which features are currently enabled.
%% This is almost static, so we go for an almost permanent state,
%% i.e., use persistent_term.
init_features() ->
    Map = init_specs(),

    persistent_term:put({?MODULE, enabled_features}, []),
    persistent_term:put({?MODULE, keywords}, []),

    RawOps = lists:filter(fun({Tag, _}) ->
                                      Tag == 'enable-feature'
                                          orelse Tag == 'disable-feature';
                                 (_) -> false
                              end,
                              init:get_arguments()),

    Cnv = fun('enable-feature') -> enable;
             ('disable-feature') -> disable
          end,

    FeatureOps = lists:append(lists:map(fun({Tag, Strings}) ->
                                                lists:map(fun(S) ->
                                                                  {Tag, S} end,
                                                          Strings)
                                        end,
                                        RawOps)),

    %% Convert failure, e.g., too long string for atom, to not
    %% being a valid feature.
    F = fun({Tag, String}) ->
                try
                    Atom = list_to_atom(String),
                    case is_configurable(Atom) of
                        true -> {true, {feature, Atom, Cnv(Tag)}};
                        false when Atom == all ->
                            {true, {feature, Atom, Cnv(Tag)}};
                        false -> false
                    end
                catch
                    _ -> false
                end
        end,
    FOps = lists:filtermap(F, FeatureOps),
    {Features, _, _} = collect_features(FOps, approved()),
    {Enabled0, Keywords} =
        lists:foldl(fun(Ftr, {Ftrs, Keys}) ->
                            case lists:member(Ftr, Ftrs) of
                                true ->
                                    {Ftrs, Keys};
                                false ->
                                    {[Ftr| Ftrs],
                                     keywords(Ftr, Map) ++ Keys}
                            end
                    end,
                    {[], []},
                    Features),

    %% Save state
    Enabled = lists:uniq(Enabled0),
    enabled_features(Enabled),
    set_keywords(Keywords),
    persistent_term:put({?MODULE, init_done}, true),
    ok.

init_specs() ->
    Specs = case os:getenv("OTP_TEST_FEATURES") of
                "true" -> test_features();
                _ -> feature_specs()
            end,
    persistent_term:put({?MODULE, feature_specs}, Specs),
    Specs.

ensure_init() ->
    case persistent_term:get({?MODULE, init_done}, false) of
        true -> ok;
        false ->
            init_features()
    end.

%% Return list of currently enabled features
-doc """
Return a list of the features that are currently enabled. Note that the set of
enabled is set during startup and can then not be changed.
""".
-doc(#{since => <<"OTP 25.0">>}).
-spec enabled() -> [feature()].
enabled() ->
    ensure_init(),
    persistent_term:get({?MODULE, enabled_features}).

enabled_features(Ftrs) ->
    persistent_term:put({?MODULE, enabled_features}, Ftrs).

%% Return list of keywords activated by enabled features
-doc false.
-spec keywords() -> [atom()].
keywords() ->
    ensure_init(),
    persistent_term:get({?MODULE, keywords}).

set_keywords(Words) ->
    persistent_term:put({?MODULE, keywords}, Words).

%% Return features used by module or beam file
-doc """
Return the list of features enabled when compiling the module. The module need
not be loaded, but is found if it exists in the loadpath. If not all features
used by the module are enabled in the runtime, loading the module is not
allowed.
""".
-doc(#{since => <<"OTP 25.0">>}).
-spec used(module() | file:filename()) -> [feature()].
used(Module) when is_atom(Module) ->
    case code:get_object_code(Module) of
        error ->
            not_found;
        {_Mod, Bin, _Fname} ->
            features_in(Bin)
    end;
used(FName) when is_list(FName) ->
    features_in(FName).

features_in(NameOrBin) ->
    case beam_lib:chunks(NameOrBin, ["Meta"], [allow_missing_chunks]) of
        {ok, {_, [{_, missing_chunk}]}} ->
            [];
        {ok, {_, [{_, Meta}]}} ->
            MetaData = erlang:binary_to_term(Meta),
            proplists:get_value(enabled_features, MetaData, []);
        _ ->
            not_found
    end.

%% Interpret feature ops (enable or disable) to build the full set of
%% features, starting from the given set. The meta feature 'all' is
%% expanded to all known features.
collect_features(FOps, Inital) ->
    collect_features(FOps, Inital, [], []).

collect_features([], Add, Del, Raw) ->
    {Add, Del, Raw};
collect_features([{feature, all, enable}| FOps], Add, _Del, Raw) ->
    All = configurable(),
    Add1 = lists:foldl(fun add_ftr/2, Add, All),
    collect_features(FOps, Add1, [], Raw);
collect_features([{feature, Feature, enable}| FOps], Add, Del, Raw) ->
    collect_features(FOps, add_ftr(Feature, Add), Del -- [Feature],
                     Raw ++ [Feature]);
collect_features([{feature, all, disable}| FOps], _Add, Del, Raw) ->
    %% Start over
    All = configurable(),
    collect_features(FOps, [], Del -- All, Raw);
collect_features([{feature, Feature, disable}| FOps], Add, Del, Raw) ->
    collect_features(FOps, Add -- [Feature],
                     add_ftr(Feature, Del),
                    Raw ++ [Feature]).

add_ftr(F, []) ->
    [F];
add_ftr(F, [F| _] = Fs) ->
    Fs;
add_ftr(F, [F0| Fs]) ->
    [F0| add_ftr(F, Fs)].

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% Test features - not present in a release
test_features() ->
    #{experimental_ftr_1 =>
          #{short => "Experimental test feature #1",
            description =>
                "Test feature in the experimental state. "
           "It is disabled by default, but can be enabled.",
            status => experimental,
            experimental => 24,
            keywords => ['ifn'],
            type => extension},
      experimental_ftr_2 =>
          #{short => "Experimental test features #2",
            description =>
                "Test feature in experimental state. "
            "It is disabled by default, but can be enabled.",
            status => experimental,
            experimental => 25,
            keywords => ['while', 'until'],
            type => extension},
      approved_ftr_1 =>
          #{short => "Approved test feature #1",
            description => "Test feature in the approved state.  "
            "It is on by default and can be disabled.",
            status => approved,
            experimental => 24,
            approved => 25,
            keywords => [],
            type => extension},
      approved_ftr_2 =>
          #{short => "Approved test feature #2",
            description =>
                "Test feature in the approved state. "
            "It is enabled by default, but can still be disabled.",
            status => approved,
            experimental => 24,
            approved => 25,
            keywords => ['unless'],
            type => extension},
      permanent_ftr =>
          #{short => "Permanent test feature",
            description => "Test feature in the permanent state.  "
            "This means it is on by default and cannot be disabled.  "
            "It is now a permanent part of Erlang/OTP.",
            status => permanent,
            experimental => 17,
            approved => 18,
            permanent => 19,
            keywords => [],
            type => extension},
      rejected_ftr =>
          #{short => "Rejected test feature.",
            description =>
                "Test feature existing only to end up as rejected. "
            "It is not available and cannot be enabled. "
            "This should be the only trace of it",
            status => rejected,
            experimental => 24,
            rejected => 25,
            keywords => ['inline', 'return', 'set'],
            type => extension}}.
