-module(albedo_cache_ttl).
%% The prompt-cache TTL table behind /cache-ttl and phase 2's warmth prior:
%% a shipped default, an optional remote copy, and a local override, merged by
%% id. Each layer file is parsed once per revision (size + mtime), like
%% albedo_models; a malformed file keeps that layer's last good entries, and
%% the typed Gleam table is decoded only when a layer revision or report changes.

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

-export([table/0, refresh/3, reload/2]).

-define(MAX_BYTES, 1048576).
-define(REFRESH_PROCESS, albedo_cache_ttl_refresh).

%% Every error this cache reports is labelled with its own name.
-define(WHAT, <<"cache-ttl">>).

%% Cache the typed Gleam table, including the current layer reports.
table() ->
    try
        Resolved = [resolve(Spec) || Spec <- specs()],
        Key = [key(L) || L <- Resolved],
        case persistent_term:get({?MODULE, merged}, undefined) of
            {Key, Table} -> {ok, Table};
            _ -> rebuild(Resolved, Key)
        end
    catch
        _:_ -> {error, <<"cache-ttl table could not be read">>}
    end.

rebuild(Resolved, Key) ->
    Entries = 'albedo@harness@cache_ttl':merge_layers(
        [{Name, Entries} || {Name, _, _, _, _, Entries} <- Resolved]),
    Table = 'albedo@harness@cache_ttl':decode_table(
        Entries, [layer_report(L) || L <- Resolved]),
    persistent_term:put({?MODULE, merged}, {Key, Table}),
    {ok, Table}.

%% default, then remote, then local: later layers replace same-id entries in
%% place, and their new ids go before every earlier-layer entry, so a local
%% override can shadow a general default while specific defaults keep their
%% place ahead of general ones.
specs() ->
    Home = albedo_extension_settings:home(),
    Default = case code:priv_dir(albedo) of
        {error, _} -> undefined;
        Dir -> filename:join(Dir, <<"cache-ttl.json">>)
    end,
    [
        {<<"default">>, Default},
        {<<"remote">>, <<Home/binary, "/cache-ttl-remote.json">>},
        {<<"local">>, <<Home/binary, "/cache-ttl.json">>}
    ].

%% {Name, Path, Revision, Loaded, Error, Entries}: the entries of the last
%% good parse survive a malformed newer file.
resolve({Name, Path}) when Path =/= undefined ->
    case file:read_file_info(Path, [{time, posix}]) of
        {ok, #file_info{type = regular, size = Size, mtime = Mtime}} when Size =< ?MAX_BYTES ->
            Revision = {Size, Mtime},
            case persistent_term:get({?MODULE, Path}, undefined) of
                {Revision, {ok, Entries}, _} ->
                    layer(Name, Path, Revision, true, undefined, Entries);
                {Revision, {error, Reason}, Previous} ->
                    layer(Name, Path, Revision, false, Reason, Previous);
                _ -> parse(Name, Path, Revision)
            end;
        {ok, #file_info{type = regular}} ->
            layer(Name, Path, missing, false, <<"cache-ttl table exceeds 1 MiB">>, previous(Path));
        {ok, _} ->
            layer(Name, Path, missing, false, <<"cache-ttl table is not a regular file">>, previous(Path));
        {error, enoent} ->
            Error = case Name of
                <<"default">> -> <<"no shipped cache-ttl table">>;
                _ -> undefined
            end,
            layer(Name, Path, missing, false, Error, []);
        {error, _} ->
            layer(Name, Path, missing, false, <<"cache-ttl table could not be read">>, previous(Path))
    end;
resolve({Name, undefined}) ->
    layer(Name, undefined, missing, false, <<"no shipped cache-ttl table">>, []).

%% Stable parse/shape failures remember the attempted revision. IO failures
%% retain the last good entries but retry on the next read, even at that revision.
parse(Name, Path, Revision) ->
    case albedo_credentials:read_json(Path) of
        {ok, Raw} ->
            save_parse(Name, Path, Revision,
                       'albedo@harness@cache_ttl':validate_layer(Raw));
        {error, invalid} ->
            save_parse(Name, Path, Revision,
                       {error, <<"cache-ttl table is not valid JSON">>});
        {error, _} ->
            layer(Name, Path, Revision, false,
                  <<"cache-ttl table could not be read">>, previous(Path))
    end.

save_parse(Name, Path, Revision, {ok, Entries}) ->
    persistent_term:put({?MODULE, Path}, {Revision, {ok, Entries}, Entries}),
    layer(Name, Path, Revision, true, undefined, Entries);
save_parse(Name, Path, Revision, {error, Reason}) ->
    Previous = previous(Path),
    persistent_term:put({?MODULE, Path}, {Revision, {error, Reason}, Previous}),
    layer(Name, Path, Revision, false, Reason, Previous).

previous(Path) ->
    case persistent_term:get({?MODULE, Path}, undefined) of
        {_, _, Previous} -> Previous;
        _ -> []
    end.

layer(Name, Path, Revision, Loaded, Error, Entries) ->
    {Name, Path, Revision, Loaded, Error, Entries}.

key({Name, Path, Revision, Loaded, Error, _}) ->
    {Name, Path, Revision, Loaded, Error}.

layer_report({Name, Path, _, Loaded, Error, _}) ->
    Optional = case Error of undefined -> none; _ -> {some, Error} end,
    {layer, Name, to_bin(Path), Loaded, Optional}.

to_bin(undefined) -> <<>>;
to_bin(Path) -> unicode:characters_to_binary(Path).

%% The remote copy is fetched and cached the way the models catalog is: a
%% background refresh when stale, an explicit reload that reports failures,
%% and a failed fetch leaving the previous cache untouched. The machinery is
%% albedo_cached_fetch; only the table check and the merged-cache reset are
%% this table's own.
refresh(Cache, Url, MaxAgeMs) ->
    albedo_cached_fetch:refresh(Cache, Url, MaxAgeMs, ?REFRESH_PROCESS,
                                ?WHAT, fun accept/1, fun after_write/1).

reload(Cache, Url) ->
    albedo_cached_fetch:reload(Cache, Url, ?WHAT, fun accept/1, fun after_write/1).

%% A fetched table is committed as it came: no fields are trimmed, it is only
%% checked to be a table, at this cache's own size cap.
accept(Body) when byte_size(Body) =< ?MAX_BYTES ->
    case json_bytes(Body) of
        {ok, Raw} ->
            case 'albedo@harness@cache_ttl':validate_layer(Raw) of
                {ok, _} -> {ok, Body};
                {error, Reason} -> {error, Reason}
            end;
        {error, invalid} -> {error, <<"cache-ttl table is not valid JSON">>}
    end;
accept(_) ->
    {error, <<"cache-ttl response is too large">>}.

%% A replacement may have the same size and timestamp; invalidate both caches.
after_write(Cache) ->
    _ = persistent_term:erase({?MODULE, to_bin(Cache)}),
    _ = persistent_term:erase({?MODULE, merged}),
    nil.

json_bytes(Body) ->
    try {ok, json:decode(Body)}
    catch _:_ -> {error, invalid}
    end.
