-module(albedo_image).
-export([inspect/1, valid/1, charset/2]).

%% The same data-size limit albedo_images guards with; a guard needs the macro.
-define(MAX_DATA_BYTES, 6990508).
-define(MAX_IMAGE_BYTES, 5242880).

inspect(Data) when is_binary(Data), byte_size(Data) > 0, byte_size(Data) =< ?MAX_DATA_BYTES ->
  try
    Bytes = base64:decode(Data),
    true = byte_size(Bytes) > 0 andalso byte_size(Bytes) =< ?MAX_IMAGE_BYTES,
    true = base64:encode(Bytes) =:= Data,
    {ok, Mime, Width, Height} = dimensions(Bytes),
    {ok, {Mime, Width, Height, byte_size(Bytes)}}
  catch _:_ -> {error, nil} end;
inspect(_) -> {error, nil}.

%% A saved types.Image whose metadata still matches its payload. Runs for every
%% image each time a transcript loads, so it proves what inspect/1 proves
%% without its two full-size copies: a scan establishes canonical base64 and the
%% decoded size, and only a prefix is decoded to read the dimensions.
valid({image, Mime, Data, Width, Height, Bytes})
    when is_binary(Mime), is_binary(Data), is_integer(Width), is_integer(Height), is_integer(Bytes),
         byte_size(Data) > 0, byte_size(Data) =< ?MAX_DATA_BYTES ->
  case canonical_size(Data) of
    Bytes when Bytes > 0, Bytes =< ?MAX_IMAGE_BYTES ->
      header_dimensions(Data) =:= {ok, Mime, Width, Height};
    _ -> false
  end;
valid(_) -> false.

%% Headers are read from this many base64 characters (48 KiB decoded); a JPEG
%% whose frame header lies beyond them is decoded whole, as before.
-define(HEADER_CHARS, 65536).

header_dimensions(Data) ->
  case byte_size(Data) > ?HEADER_CHARS andalso dimensions(base64:decode(binary:part(Data, 0, ?HEADER_CHARS))) of
    {ok, Mime, _, _} = Found when Mime =/= <<"image/webp">> -> Found;
    _ -> dimensions(base64:decode(Data))  %% RIFF size covers the whole file; large JPEGs decoded whole
  end.

%% The decoded size of canonical, padded base64 (what base64:encode/1 emits),
%% or error. Canonical means encode(decode(Data)) =:= Data: standard alphabet,
%% length a multiple of four, at most two '=' at the end, and zero unused bits
%% in the final character.
canonical_size(Data) when byte_size(Data) >= 4, byte_size(Data) rem 4 =:= 0 ->
  Body = byte_size(Data) - 4,
  <<Head:Body/binary, Last:4/binary>> = Data,
  case alphabet(Head) of
    true ->
      case Last of
        <<A, B, $=, $=>> -> quad_tail(<<A, B>>, B, 16#0F, 1, Body);
        <<A, B, C, $=>> -> quad_tail(<<A, B, C>>, C, 16#03, 2, Body);
        <<_, _, _, D>> -> quad_tail(Last, D, 0, 3, Body);
        _ -> error
      end;
    false -> error
  end;
canonical_size(_) -> error.

quad_tail(Prefix, Last, Mask, Extra, Body) ->
  case alphabet(Prefix) andalso value(Last) band Mask =:= 0 of
    true -> Body div 4 * 3 + Extra;
    false -> error
  end.

%% One base64 table for both users: albedo_images also accepts '=', which
%% JSON never needs escaped, so a payload may carry it anywhere and still
%% store clean.
alphabet(Value) -> charset(Value, false).

charset(<<C, Rest/binary>>, Padding)
    when (C >= $A andalso C =< $Z); (C >= $a andalso C =< $z);
         (C >= $0 andalso C =< $9); C =:= $+; C =:= $/ -> charset(Rest, Padding);
charset(<<$=, Rest/binary>>, true) -> charset(Rest, true);
charset(<<>>, _) -> true;
charset(_, _) -> false.

value(C) when C >= $A, C =< $Z -> C - $A;
value(C) when C >= $a, C =< $z -> C - $a + 26;
value(C) when C >= $0, C =< $9 -> C - $0 + 52;
value($+) -> 62;
value($/) -> 63.

dimensions(<<16#89, "PNG", 13, 10, 26, 10, 13:32/big, "IHDR", Width:32/big, Height:32/big, _/binary>>)
    when Width > 0, Height > 0 -> {ok, <<"image/png">>, Width, Height};
dimensions(<<16#FF, 16#D8, Rest/binary>>) -> jpeg(Rest);
dimensions(<<"RIFF", Size:32/little, "WEBP", Chunks/binary>> = Bytes)
    when Size + 8 =:= byte_size(Bytes) -> webp(Chunks);
dimensions(_) -> error.

jpeg(<<16#FF, Rest/binary>>) -> jpeg_marker(Rest);
jpeg(<<_, Rest/binary>>) -> jpeg(Rest);
jpeg(<<>>) -> error.

jpeg_marker(<<16#FF, Rest/binary>>) -> jpeg_marker(Rest);
jpeg_marker(<<Marker, Rest/binary>>) when Marker =:= 16#D8; Marker =:= 16#01;
    (Marker >= 16#D0 andalso Marker =< 16#D7) -> jpeg(Rest);
jpeg_marker(<<Marker, Length:16/big, Payload:(Length - 2)/binary, Tail/binary>>) when Length >= 2 ->
  case is_sof(Marker) of
    true ->
      case Payload of
        <<_Precision, Height:16/big, Width:16/big, _/binary>> when Width > 0, Height > 0 ->
          {ok, <<"image/jpeg">>, Width, Height};
        _ -> error
      end;
    false when Marker =:= 16#DA; Marker =:= 16#D9 -> error;
    false -> jpeg(Tail)
  end;
jpeg_marker(_) -> error.

is_sof(Marker) ->
  lists:member(Marker, [16#C0,16#C1,16#C2,16#C3,16#C5,16#C6,16#C7,
                        16#C9,16#CA,16#CB,16#CD,16#CE,16#CF]).

webp(<<"VP8X", 10:32/little, _Flags, _Reserved:24,
       WidthMinusOne:24/little, HeightMinusOne:24/little, _/binary>>) ->
  {ok, <<"image/webp">>, WidthMinusOne + 1, HeightMinusOne + 1};
webp(<<"VP8L", Size:32/little, 16#2F, Bits:32/little, _/binary>>) when Size >= 5 ->
  {ok, <<"image/webp">>, (Bits band 16#3FFF) + 1, ((Bits bsr 14) band 16#3FFF) + 1};
webp(<<"VP8 ", Size:32/little, _FrameTag:24/little, 16#9D, 16#01, 16#2A,
       WidthBits:16/little, HeightBits:16/little, _/binary>>) when Size >= 10 ->
  Width = WidthBits band 16#3FFF,
  Height = HeightBits band 16#3FFF,
  case Width > 0 andalso Height > 0 of
    true -> {ok, <<"image/webp">>, Width, Height};
    false -> error
  end;
webp(<<_:4/binary, Size:32/little, _:Size/binary, _:(Size rem 2)/binary, Tail/binary>>) ->
  webp(Tail);
webp(_) -> error.
