src/t__srv.erl

%% -*- coding: utf-8 -*-
%% Copyright (c) 2022, Madalin Grigore-Enescu <https://github.com/ergenius> <https://ergenius.com>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
%% copyright notice and this permission notice appear in all copies.
%%
%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
-module(t__srv).
-author("Madalin Grigore-Enescu").

-behaviour(gen_server).

-include("../include/t__.hrl").

%% API
-export([start_link/1]).

%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).

-export([config_set_call/3, config_set_cast/2]).
-export([config_delete_call/2, config_delete_cast/1]).

-record(t__srv_state, {
	configs = [] :: proplists:proplist(),
	dev_count = 0,
	timer_ref = undefined,
	timer_interval = 10000
}).

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% API
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

-spec start_link(Args :: proplists:proplist()) -> {'ok', pid()} | 'ignore' | {'error', term()}.
%% @doc Creates a gen_server process as part of a supervision tree.
%% The function should be called, directly or indirectly, by the supervisor.
%% It will, among other things, ensure that the gen_server is linked to the supervisor.
start_link(Args) -> gen_server:start_link({local, ?MODULE}, ?MODULE, Args, []).

%% @doc Config set
%% See t__:config_set/3 for more details
config_set_call(Application, Config, Timeout) -> gen_server:call(?MODULE, {config_set, Application, Config}, Timeout).

%% @doc Config set
%% See t__:config_set_cast/3 for more details
config_set_cast(Application, Config) -> gen_server:cast(?MODULE, {config_set, Application, Config}).

%% @doc Config delete
%% See t__:config_delete/2 for more details
config_delete_call(Application, Timeout) -> gen_server:call(?MODULE, {config_delete, Application}, Timeout).

%% @doc Config delete
%% See t__:config_delete_cast/2 for more details
config_delete_cast(Application) -> gen_server:cast(?MODULE, {config_delete, Application}).

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% INIT
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%% @doc Init gen_server
%% Whenever a gen_server process is started using start/3,4 or start_link/3,4, this function is called by the new process to initialize.
%%
%% Args is the Args argument provided to the start function.
%% If the initialization is successful, the function is to return {ok,State}, {ok,State,Timeout}, or {ok,State,hibernate},
%% where State is the internal state of the gen_server process.
%%
%% If an integer time-out value is provided, a time-out occurs unless a request or a message is received within Timeout milliseconds.
%% A time-out is represented by the atom timeout, which is to be handled by the Module:handle_info/2 callback function.
%% The atom infinity can be used to wait indefinitely, this is the default value.
%%
%% If hibernate is specified instead of a time-out value, the process goes into hibernation when waiting for the next message to arrive
%% (by calling proc_lib:hibernate/3).
%%
%% If the initialization fails, the function is to return {stop, Reason}, where Reason is any term, or ignore.
init(Args) -> private_handle_init(Args).

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% handle_call
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%% @doc Handle gen_server calls
%% Whenever a gen_server process receives a request sent using call/2,3 or multi_call/2,3,4, this function is called to handle the request.
%%
%% Request is the Request argument provided to call or multi_call.
%% From is a tuple {Pid,Tag}, where Pid is the pid of the client and Tag is a unique tag.
%% State is the internal state of the gen_server process.
%%
%% If {reply,Reply,NewState} is returned, {reply,Reply,NewState,Timeout} or {reply,Reply,NewState,hibernate},
%% Reply is given back to From as the return value of call/2,3 or included in the return value of multi_call/2,3,4.
%% The gen_server process then continues executing with the possibly updated internal state NewState.
%%
%% For a description of Timeout and hibernate, see Module:init/1.
%%
%% If {noreply,NewState} is returned, {noreply,NewState,Timeout}, or {noreply,NewState,hibernate},
%% the gen_server process continues executing with NewState. Any reply to From must be specified explicitly using reply/2.
%%
%% If {stop,Reason,Reply,NewState} is returned, Reply is given back to From.
%%
%% If {stop,Reason,NewState} is returned, any reply to From must be specified explicitly using reply/2.
%% The gen_server process then calls Module:terminate(Reason,NewState) and terminates.

%% config_set
handle_call({config_set, Application, Config = #t__config{}}, _From,
	State = #t__srv_state{}) when erlang:is_atom(Application) ->
	{Reply, NewState} = private_handle_config_set(Application, Config, State),
	{reply, Reply, NewState};

%% config_delete
handle_call({config_delete, Application}, _From,
	State = #t__srv_state{}) when erlang:is_atom(Application) ->
	{Reply, NewState} = private_handle_config_delete(Application, State),
	{reply, Reply, NewState};

%% Handle any other call
handle_call(_Msg, _From, State) -> {reply, error, State}.

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% handle_cast
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%% @doc Handle gen_server casts
%% Whenever a gen_server process receives a request sent using cast/2 or abcast/2,3, this function is called to handle the request.
%% For a description of the arguments and possible return values, see Module:handle_call/3.

%% config_set
handle_cast({config_set, Application, Config = #t__config{}},
	State = #t__srv_state{}) when erlang:is_atom(Application) ->
	{_, NewState} = private_handle_config_set(Application, Config, State),
	{noreply, NewState};

%% config_delete
handle_cast({config_delete, Application},
	State = #t__srv_state{}) when erlang:is_atom(Application) ->
	{_, NewState} = private_handle_config_delete(Application, State),
	{noreply, NewState};

%% Handle any other cast
handle_cast(_Msg, State) -> {noreply, State}.

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% handle_info
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%% @doc Handle gen_server info
%% This function is called by a gen_server process when a time-out occurs or when it receives any other message
%% than a synchronous or asynchronous request (or a system message).
%% Info is either the atom timeout, if a time-out has occurred, or the received message.
%% For a description of the other arguments and possible return values, see Module:handle_call/3.

%% Handle monitor
handle_info(monitor, State) -> private_handle_monitor(State);

%% Handle any other info
handle_info(_Info, State) -> {noreply, State}.

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% terminate/code_change
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%% @doc Handle gen_server terminate
%%
%% This function is called by a gen_server process when it is about to terminate.
%% It is to be the opposite of Module:init/1 and do any necessary cleaning up.
%% When it returns, the gen_server process terminates with Reason. The return value is ignored.
%%
%% Reason is a term denoting the stop reason and State is the internal state of the gen_server process.
%%
%% Reason depends on why the gen_server process is terminating. If it is because another callback function has returned a stop tuple {stop,..},
%% Reason has the value specified in that tuple. If it is because of a failure, Reason is the error reason.
%%
%% If the gen_server process is part of a supervision tree and is ordered by its supervisor to terminate, this function is called with Reason=shutdown
%% if the following conditions apply:
%% - The gen_server process has been set to trap exit signals.
%% - The shutdown strategy as defined in the child specification of the supervisor is an integer time-out value, not brutal_kill.
%%
%% Even if the gen_server process is not part of a supervision tree, this function is called if it receives an 'EXIT' message from its parent.
%% Reason is the same as in the 'EXIT' message.
%% Otherwise, the gen_server process terminates immediately.
%%
%% Notice that for any other reason than normal, shutdown, or {shutdown,Term}, the gen_server process is assumed to terminate
%% because of an error and an error report is issued using error_logger:format/2.
terminate(_Reason, State) -> private_handle_terminate(State).

%% @doc Handle gen_server code change
%% This function is called by a gen_server process when it is to update its internal state during a release upgrade/downgrade,
%% that is, when the instruction {update,Module,Change,...}, where Change={advanced,Extra}, is specifed in the appup file.
%% For more information, see section Release Handling Instructions in OTP Design Principles.
%%
%% For an upgrade, OldVsn is Vsn, and for a downgrade, OldVsn is {down,Vsn}. Vsn is defined by the vsn attribute(s) of the old version
%% of the callback module Module. If no such attribute is defined, the version is the checksum of the Beam file.
%%
%% State is the internal state of the gen_server process.
%% Extra is passed "as is" from the {advanced,Extra} part of the update instruction.
%%
%% If successful, the function must return the updated internal state.
%% If the function returns {error,Reason}, the ongoing upgrade fails and rolls back to the old release.
code_change(_OldVsn, State, _Extra) -> {ok, State}.

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% private_handle
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%%=============================================================
%% private_handle_init
%%=============================================================

%% @doc Init gen_server
private_handle_init(_Args) -> private_handle_init(application:loaded_applications(), #t__srv_state{}).
private_handle_init([{Application, _Description, _Vsn} | T],
	State = #t__srv_state{
		configs = Configs
	}) ->
	case t__:config_get_environment(Application) of
		{ok, Config} ->
			%% Add configured applications
			private_handle_init(T, State#t__srv_state{
				configs = [{Application, Config} | Configs]
			});
		_ ->
			%% Skip applications not configured
			private_handle_init(T, State)
	end;
private_handle_init([], State) ->
	{ok, private_state_on_change(private_update_applications(State)), infinity}.

%%=============================================================
%% private_handle_config_set
%%=============================================================

%% @doc config_set
private_handle_config_set(Application, Config = #t__config{}, State = #t__srv_state{}) ->
	{_, DeleteState} = private_handle_config_delete(Application, State),
	DeleteStateConfigs = DeleteState#t__srv_state.configs,
	{ok, private_state_on_change(private_update_applications(DeleteState#t__srv_state{
		configs = [{Application, Config} | DeleteStateConfigs]
	}))}.

%%=============================================================
%% private_handle_config_delete
%%=============================================================

%% @doc Delete specified application configuration
private_handle_config_delete(Application, State = #t__srv_state{
	configs = Configs
}) ->
	case proplists:get_value(Application, Configs, undefined) of
		#t__config{
			repositories = Repositories
		} ->
			private_delete_repositories(Application, Repositories),
			NewConfigs = proplists:delete(Application, Configs),
			application:unset_env(Application, t__),
			{ok, private_state_on_change(State#t__srv_state{configs = NewConfigs})};
		_ ->
			{{error, not_found}, State}
	end.

%%=============================================================
%% private_handle_monitor
%%=============================================================

%% @doc Monitor all configured applications
private_handle_monitor(State) ->
	NewState = private_update_applications(State),
	{noreply, private_state_on_change(NewState#t__srv_state{
		timer_ref = undefined
	})}.

%%=============================================================
%% private_handle_terminate
%%=============================================================

%% @doc Handle gen_server terminate
private_handle_terminate(State = #t__srv_state{
	configs = [{Application, #t__config{
		repositories = Repositories}}|T]}) ->
	private_delete_repositories(Application, Repositories),
	private_handle_terminate(State#t__srv_state{configs =T});
private_handle_terminate(#t__srv_state{}) ->
	%% POT update
	ok.

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% private_
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%%=============================================================
%% private_update_applications
%%=============================================================

%% @doc Update all applications
private_update_applications(State=#t__srv_state{configs = Configs}) ->
	State#t__srv_state{configs = private_update_applications(Configs, [])}.
private_update_applications([{ApplicationName, Config}|T], Acum) ->
	NewConfig = private_update_application(ApplicationName, Config),
	private_update_applications(T, [{ApplicationName, NewConfig}|Acum]);
private_update_applications([], Acum) -> Acum.

%% @doc Iterate all application repositories and update them
%% Ignore applications without repositories and handle only applications that have repositories
private_update_application(_ApplicationName, Config = #t__config{repositories = undefined}) ->
	Config;
private_update_application(ApplicationName, Config = #t__config{
	repositories = Repositories
}) when erlang:is_list(Repositories) ->
	Config#t__config{
		repositories = private_update_application(ApplicationName, Repositories, [])
	}.

%% @doc Update the specified application
private_update_application(ApplicationName, [Repository = #t__repository{}|T], Acum) ->
	case t__repository:update(ApplicationName, Repository) of
		{ok, NewRepository} -> private_update_application(ApplicationName, T, [NewRepository|Acum]);
		Error ->
			?T__LOG(error, "Updating application repository failed with error."
			" Repository directory does not exist or it is not accesible.",
				[
					{application, ApplicationName},
					{repository, Repository},
					{error, Error}
				]),
			%% We are facing 3 choices here:
			%% 1. exit()
			%% 2. keep the repository and generate errors on each timer info
			%% 3. delete the repository and silence the errors
			%%
			%% Do not forget we may encounter this situation only when development mode is enabled.
		    %% I do believe it's better to chose 2 on a developer setup. Maybe the DEV can fix the problem
			%% without us crushing the application and continue his work of changing repositories
			%% so... do nothing about it!
			private_update_application(ApplicationName, T, [Repository|Acum])
	end;
private_update_application(_ApplicationName, _, Acum) -> Acum.

%%=============================================================
%% private_state_on_change
%%=============================================================

%% @doc Update state on configs change
private_state_on_change(State = #t__srv_state{configs = Configs, timer_ref = TimerRef, timer_interval = TimerInterval}) ->
	%% Count configs with dev enabled
	NewDevCount = private_state_on_change_dev_count(Configs),
	%% Start a new timer for monitoring PO file changes if necessary
	NewTimerRef = case {NewDevCount > 0, TimerRef} of
					  %% We don't have a timer, create new
					  {true, undefined} -> erlang:send_after(TimerInterval, self(), monitor);
					  %% We already have a timer, return it
					  {true, _} -> TimerRef;
					  %% Nothing left to monitor, no timer
					  {false, undefined} -> undefined;
					  %% Nothing left to monitor, stop existing timer
					  {false, _} -> erlang:cancel_timer(TimerRef, [{async, true}]), undefined
				  end,
	%%?T__LOG(debug, "t__srv state changed", [State#t__srv_state{dev_count = NewDevCount, timer_ref = NewTimerRef}]),
	State#t__srv_state{dev_count = NewDevCount, timer_ref = NewTimerRef}.

%% @doc Count the dev in configs proplist
private_state_on_change_dev_count(Configs) -> private_state_on_change_dev_count(Configs, 0).
private_state_on_change_dev_count([{_, #t__config{dev = true}} | T], Acum) -> private_state_on_change_dev_count(T, Acum + 1);
private_state_on_change_dev_count([_ | T], Acum) -> private_state_on_change_dev_count(T, Acum);
private_state_on_change_dev_count([], Acum) -> Acum.

%%=============================================================
%% private_delete_repositories
%%=============================================================

%% @doc Delete the specified application repositories
private_delete_repositories(Application, [#t__repository{name=RepositoryName}|T]) ->
	RepositoryEtsTable = t__repository:ets_table(Application, RepositoryName),
	try ets:delete(RepositoryEtsTable)
		catch _Exception:_Reason -> false
	end,
	private_delete_repositories(Application, T);
private_delete_repositories(_Application, []) -> ok.