%%%-------------------------------------------------------------------
%%% File    : tft_file.erl
%%% Author  : Hakan Mattsson <hakan@erix.ericsson.se>
%%% Description : 
%%%
%%% Created : 24 May 2004 by Hakan Mattsson <hakan@erix.ericsson.se>
%%%-------------------------------------------------------------------

-module(tftp_file).

%%%-------------------------------------------------------------------
%%% Interface
%%%-------------------------------------------------------------------

-behaviour(tftp).

-export([prepare/6, open/6, read/1, write/2, abort/3]).

%%%-------------------------------------------------------------------
%%% Defines
%%%-------------------------------------------------------------------

-include_lib("kernel/include/file.hrl").

-record(initial,
	{filename,
	 is_native_ascii}).

-record(state,
	{access,
	 filename,
	 is_native_ascii,
	 is_network_ascii,
	 root_dir,
	 options,
	 blksize,
	 fd,
	 count,
	 buffer}).

%%-------------------------------------------------------------------
%% prepare(Peer, Access, Filename, Mode, SuggestedOptions, InitialState) -> 
%%    {ok, AcceptedOptions, NewState} | {error, Code, Text}
%%
%% Peer             = {PeerType, PeerHost, PeerPort}
%% PeerType         = inet | inet6
%% PeerHost         = ip_address()
%% PeerPort         = integer()
%% Acess            = read | write
%% Filename         = string()
%% Mode             = string()
%% SuggestedOptions = [{Key, Value}]
%% AcceptedOptions  = [{Key, Value}]
%% Key              = string()
%% Value            = string()
%% InitialState     = [] | [{root_dir, string()}]
%% NewState         = term()
%% Code             = undef | enoent | eacces | enospc |
%%                    badop | eexist | baduser | badopt |
%%                    integer()
%% Text             = string()
%%
%% Prepares open of a file on the client side.
%% 
%% Will be followed by a call to open/4 before any read/write access
%% is performed. The AcceptedOptions will be sent to the server which
%% will reply with those options that it accepts. The options that are
%% accepted by the server will be forwarded to open/4 as SuggestedOptions.
%%
%% No new options may be added, but the ones that are present as
%% SuggestedOptions may be omitted or replaced with new values
%% in the AcceptedOptions.
%%-------------------------------------------------------------------

prepare(_Peer, Access, Filename, Mode, SuggestedOptions, Initial) when is_list(Initial) ->
    %% Client side
    case catch handle_options(Access, Filename, Mode, SuggestedOptions, Initial) of
	{ok, Filename2, IsNativeAscii, IsNetworkAscii, AcceptedOptions} ->
	    State = #state{access           = Access,
			   filename         = Filename2,
			   is_native_ascii  = IsNativeAscii,
			   is_network_ascii = IsNetworkAscii,
			   options  	    = AcceptedOptions,
			   blksize  	    = lookup_blksize(AcceptedOptions),
			   count    	    = 0,
			   buffer   	   =  []},
	    {ok, AcceptedOptions, State};
	{error, {Code, Text}} ->
	    {error, {Code, Text}}
    end.

%% ---------------------------------------------------------
%% open(Peer, Access, Filename, Mode, SuggestedOptions, State) -> 
%%    {ok, AcceptedOptions, NewState} | {error, Code, Text}
%%
%% Peer             = {PeerType, PeerHost, PeerPort}
%% PeerType         = inet | inet6
%% PeerHost         = ip_address()
%% PeerPort         = integer()
%% Acess            = read | write
%% Filename         = string()
%% Mode             = string()
%% SuggestedOptions = [{Key, Value}]
%% AcceptedOptions  = [{Key, Value}]
%% Key              = string()
%% Value            = string()
%% State            = InitialState | #state{}
%% InitialState     = [] | [{root_dir, string()}]
%% NewState         = term()
%% Code             = undef | enoent | eacces  | enospc |
%%                    badop | eexist | baduser | badopt |
%%                    integer()
%% Text             = string()
%%
%% Opens a file for read or write access.
%% 
%% On the client side where the open/4 call has been preceeded by a
%% call to prepare/4, all options must be accepted or rejected.
%% On the server side, where there are no preceeding prepare/4 call,
%% noo new options may be added, but the ones that are present as
%% SuggestedOptions may be omitted or replaced with new values
%% in the AcceptedOptions.
%%-------------------------------------------------------------------

open(Peer, Access, Filename, Mode, SuggestedOptions, Initial) when is_list(Initial) ->
    %% Server side
    case prepare(Peer, Access, Filename, Mode, SuggestedOptions, Initial) of
	{ok, AcceptedOptions, State} ->
	    open(Peer, Access, Filename, Mode, AcceptedOptions, State);
	{error, {Code, Text}} ->
	    {error, {Code, Text}}
    end;
open(_Peer, Access, Filename, Mode, NegotiatedOptions, State) when is_record(State, state) ->
    %% Both sides
    case catch handle_options(Access, Filename, Mode, NegotiatedOptions, State) of
	{ok, _Filename2, _IsNativeAscii, _IsNetworkAscii, Options} 
	   when Options =:= NegotiatedOptions ->
	    do_open(State);
	{error, {Code, Text}} ->
	    {error, {Code, Text}}
    end;
open(Peer, Access, Filename, Mode, NegotiatedOptions, State) ->
    %% Handle upgrade from old releases. Please, remove this clause in next release.
    State2 = upgrade_state(State),
    open(Peer, Access, Filename, Mode, NegotiatedOptions, State2).

do_open(State) when is_record(State, state) ->
    case file:open(State#state.filename, file_options(State)) of
	{ok, Fd} ->
	    {ok, State#state.options, State#state{fd = Fd}};
	{error, Reason} when is_atom(Reason) ->
	    {error, file_error(Reason)}
    end.
	
file_options(State) ->
    case State#state.access of
	read  -> [read, read_ahead, raw, binary];
	write -> [write, delayed_write, raw, binary]
    end.

file_error(Reason) when is_atom(Reason) ->
    Details = file:format_error(Reason),
    case Reason of
	eexist -> {Reason, Details};
	enoent -> {Reason, Details};
	eacces -> {Reason, Details};
	eperm  -> {eacces, Details};
	enospc -> {Reason, Details};
	_      -> {undef,  Details ++ " (" ++ atom_to_list(Reason) ++ ")"}
    end.

%%-------------------------------------------------------------------
%% read(State) ->
%%   {more, Bin, NewState} | {last, Bin, FileSize} | {error, {Code, Text}}
%%
%% State    = term()
%% NewState = term()
%% Bin      = binary()
%% FileSize = integer()
%% Code     = undef | enoent | eacces  | enospc |
%%            badop | eexist | baduser | badopt |
%%            integer()
%% Text     = string()
%%
%% Reads a chunk from the file
%% 
%% The file is automatically closed when the last chunk is read.
%%-------------------------------------------------------------------

read(#state{access = read} = State) ->
    BlkSize = State#state.blksize,
    case file:read(State#state.fd, BlkSize) of
	{ok, Bin} when is_binary(Bin), size(Bin) =:= BlkSize ->
	    Count = State#state.count + size(Bin),
	    {more, Bin, State#state{count = Count}};
	{ok, Bin} when is_binary(Bin), size(Bin) < BlkSize ->
	    file:close(State#state.fd),
	    Count = State#state.count + size(Bin),
	    {last, Bin, Count};
	eof ->
	    {last, <<>>, State#state.count};
	{error, Reason} ->
	    file:close(State#state.fd),
	    {error, file_error(Reason)}
    end;
read(State) ->
    %% Handle upgrade from old releases. Please, remove this clause in next release.
    State2 = upgrade_state(State),
    read(State2).

%%-------------------------------------------------------------------
%% write(Bin, State) ->
%%   {more, NewState} | {last, FileSize} | {error, {Code, Text}}
%%
%% State    = term()
%% NewState = term()
%% Bin      = binary()
%% FileSize = integer()
%% Code     = undef | enoent | eacces  | enospc |
%%            badop | eexist | baduser | badopt |
%%            integer()
%% Text     = string()
%%
%% Writes a chunk to the file
%%
%% The file is automatically closed when the last chunk is written
%%-------------------------------------------------------------------

write(Bin, #state{access = write} = State) when is_binary(Bin) ->
    Size = size(Bin),
    BlkSize = State#state.blksize,
    case file:write(State#state.fd, Bin) of
	ok when Size =:= BlkSize->
	    Count = State#state.count + Size,
	    {more, State#state{count = Count}};
	ok when Size < BlkSize->
	    file:close(State#state.fd),
	    Count = State#state.count + Size,
	    {last, Count};
	{error, Reason}  ->
	    file:close(State#state.fd),
	    file:delete(State#state.filename),
	    {error, file_error(Reason)}
    end;
write(Bin, State) ->
    %% Handle upgrade from old releases. Please, remove this clause in next release.
    State2 = upgrade_state(State),
    write(Bin, State2).

%%-------------------------------------------------------------------
%% abort(Code, Text, State) -> ok
%% 
%% State    = term()
%% Code     = undef  | enoent | eacces  | enospc |
%%            badop  | eexist | baduser | badopt |
%%            badblk | integer()
%% Text     = string()
%%
%% Aborts the file transfer
%%-------------------------------------------------------------------

abort(_Code, _Text, #state{fd = Fd, access = Access} = State) ->
    file:close(Fd),
    case Access of
	write ->
	    ok = file:delete(State#state.filename);
	read ->
	    ok
    end.

%%-------------------------------------------------------------------
%% Process options
%%-------------------------------------------------------------------

handle_options(Access, Filename, Mode, Options, Initial) ->
    I = #initial{filename = Filename, is_native_ascii = is_native_ascii()},
    {Filename2, IsNativeAscii} = handle_initial(Initial, I),
    IsNetworkAscii = handle_mode(Mode, IsNativeAscii),
    Options2 = do_handle_options(Access, Filename2, Options),
    {ok, Filename2, IsNativeAscii, IsNetworkAscii, Options2}.

handle_mode(Mode, IsNativeAscii) ->
    case Mode of
	"netascii" when IsNativeAscii =:= true -> true;
	"octet" -> false;
	_ -> throw({error, {badop, "Illegal mode " ++ Mode}})
    end.

handle_initial([{root_dir, Dir} | Initial], I) ->
    case catch filename_join(Dir, I#initial.filename) of
	{'EXIT', _} ->
	    throw({error, {badop, "Internal error. root_dir is not a string"}});
	Filename2 ->
	    handle_initial(Initial, I#initial{filename = Filename2})
    end;
handle_initial([{native_ascii, Bool} | Initial], I) ->
    case Bool of
	true  -> handle_initial(Initial, I#initial{is_native_ascii = true});
	false -> handle_initial(Initial, I#initial{is_native_ascii = false})
    end;
handle_initial([], I) when is_record(I, initial) ->
    {I#initial.filename, I#initial.is_native_ascii};
handle_initial(State, _) when is_record(State, state) ->
    {State#state.filename, State#state.is_native_ascii}.

filename_join(Dir, Filename) ->
    case filename:pathtype(Filename) of
	absolute ->
	    [_ | RelFilename] = filename:split(Filename),
	    filename:join([Dir, RelFilename]);
	_ ->
	    filename:join([Dir, Filename])
    end.

do_handle_options(Access, Filename, [{Key, Val} | T]) ->
    case Key of
	"tsize" ->
	    case Access of
		read when Val =:= "0" ->
		    case file:read_file_info(Filename) of
			{ok, FI} ->
			    Tsize = integer_to_list(FI#file_info.size),
			    [{Key, Tsize} | do_handle_options(Access, Filename, T)];
			{error, _} ->
			    do_handle_options(Access, Filename, T)
		    end;
		_ ->
		    handle_integer(Access, Filename, Key, Val, T, 0, infinity)
	    end;
	"blksize" ->
	    handle_integer(Access, Filename, Key, Val, T, 8, 65464);
	"timeout" ->
	    handle_integer(Access, Filename, Key, Val, T, 1, 255);
	_ ->
	    do_handle_options(Access, Filename, T)
    end;
do_handle_options(_Access, _Filename, []) ->
    [].


handle_integer(Access, Filename, Key, Val, Options, Min, Max) ->
    case catch list_to_integer(Val) of
	{'EXIT', _} ->
	    do_handle_options(Access, Filename, Options);
	Int when Int >= Min, Int =< Max ->
	    [{Key, Val} | do_handle_options(Access, Filename, Options)];
	Int when Int >= Min, Max =:= infinity ->
	    [{Key, Val} | do_handle_options(Access, Filename, Options)];
	_Int ->
	    throw({error, {badopt, "Illegal " ++ Key ++ " value " ++ Val}})
    end.

lookup_blksize(Options) ->
    case lists:keysearch("blksize", 1, Options) of
	{value, {_, Val}} ->
	    list_to_integer(Val);
	false ->
	    512
    end.

is_native_ascii() ->
    case os:type() of
	{win32, _} -> true;
	_          -> false
    end.

%% Handle upgrade from old releases. Please, remove this function in next release.
upgrade_state({state, Access, Filename, RootDir, Options, BlkSize, Fd, Count, Buffer}) ->
    {state, Access, Filename, false, false, RootDir, Options, BlkSize, Fd, Count, Buffer}.
