#!/bin/bash
#   BAREOS® - Backup Archiving REcovery Open Sourced
#
#   Copyright (C) 2026-2026 Bareos GmbH & Co. KG
#
#   This program is Free Software; you can redistribute it and/or
#   modify it under the terms of version three of the GNU Affero General Public
#   License as published by the Free Software Foundation and included
#   in the file LICENSE.
#
#   This program is distributed in the hope that it will be useful, but
#   WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
#   Affero General Public License for more details.
#
#   You should have received a copy of the GNU Affero General Public License
#   along with this program; if not, write to the Free Software
#   Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
#   02110-1301, USA.

set -e
set -o pipefail
set -u
#
# Test the output of the status subscription command
#
TestName="$(basename "$(pwd)")"
export TestName

#shellcheck source=../../environment.in
. ./environment

JobName=bconsole-status-client
#shellcheck source=../../scripts/functions
. "${BAREOS_SCRIPTS_DIR}"/functions

start_test

rm -f "$tmp"/status-subscription*.txt "$tmp"/status-subscription*.json

# Generate a set of test clients and filesets together with a bunch of jobs

cat <<END_OF_DATA >$tmp/bconcmds
@$out $tmp/status-subscriptions-setup.txt
use
sqlquery sharedsession
DELETE FROM Job WHERE JobId >= 10000;
DELETE FROM Client WHERE ClientId >= 10000;
DELETE FROM Fileset WHERE FileSetId >= 10000;
INSERT INTO Client (ClientId, Name, UName) VALUES
(10000, 'file-only', 'synthetic test client'),
(10001, 'two-barri-jobs', 'synthetic test client'),
(10002, 'file-and-plugins', 'synthetic test client'),
(10003, 'native-plugins', 'synthetic test client'),
(10004, 'python-plugins', 'synthetic test client'),
(10005, 'special-syntax', 'synthetic test client');
INSERT INTO Fileset (FileSetId, Fileset, FileSetText, Md5, CreateTime) VALUES
(10000, 'file1', E'{\n{\nFile = "/a"\n}\n}', 'x', now()),
(10001, 'file2', E'{\n{\nFile = "/b"\n}\n}', 'x', now()),
(10002, 'file3', E'{\n{\nFile = "/c"\n}\n}', 'x', now()),
(10003, 'barri1', E'{\n{\nPlugin = "barri"\n}\n}', 'x', now()),
(10004, 'barri2', E'{\n{\nPlugin = "barri:"\n}\n}', 'x', now()),
(10005, 'bpipe1', E'{\n{\nPlugin = "bpipe:reader=\\"/bin/prog\\""\n}\n}', 'x', now()),
(10006, 'hyper-v1', E'{\n{\nPlugin = "hyper-v:"\n}\n}', 'x', now()),
(10007, 'hyper-v2', E'{\n{\nPlugin = "hyper-v:"\n}\n}', 'x', now()),
(10008, 'hyper-v3', E'{\n{\nPlugin = "hyper-v"\n}\n}', 'x', now()),
(10009, 'mssql1', E'{\n{\nPlugin = "mssqlvdi:"\n}\n}', 'x', now()),
(10010, 'mssql2', E'{\n{\nPlugin = "Mssqlvdi:"\n}\n}', 'x', now()),
(10011, 'mssql3', E'{\n{\nPlugin = "MSSQLVDI:"\n}\n}', 'x', now()),
(10012, 'mssql4', E'{\n{\nPlugin = "mssqlvdi"\n}\n}', 'x', now()),
(10013, 'ldap', E'{\n{\nPlugin = "python:module_name=bareos-fd-ldap"\n}\n}', 'x', now()),
(10014, 'libcloud', E'{\n{\nPlugin = "python:module_name=bareos-fd-libcloud"\n}\n}', 'x', now()),
(10015, 'mariadb', E'{\n{\nPlugin = "python3:module_name=bareos-fd-mariabackup"\n}\n}', 'x', now()),
(10016, 'ovirt', E'{\n{\nPlugin = "python:module_name=bareos-fd-ovirt"\n}\n}', 'x', now()),
(10017, 'percona', E'{\n{\nPlugin = "python:module_name=bareos-fd-percona"\n}\n}', 'x', now()),
(10018, 'postgres', E'{\n{\nPlugin = "python:module_name=bareos-fd-postgres"\n}\n}', 'x', now()),
(10019, 'proxmox', E'{\n\{\nPlugin = "python:module_name=bareos-fd-proxmox"\n}\n}', 'x', now()),
(10020, 'qumulo', E'{\n{\nPlugin = "python:module_name=bareos-fd-qumulo"\n}\n}', 'x', now()),
(10021, 'vmware', E'{\n{\nPlugin = "python:module_name=bareos-fd-vmware"\n}\n}', 'x', now()),
(10022, 'nomod-py', E'{\n{\nPlugin = "python3:no_module_name"\n}\n}', 'x', now()),
(10023, 'custom', E'{\n{\nPlugin = "custom-plugin"\n}\n}', 'x', now()),
(10024, 'grpc1', E'{\n{\nPlugin = "grpc:bareos-grpc-fd-plugin-bridge:python3:arg1:arg2:module_name=bareos-fd-bridged"\n}\n}', 'x', now()),
(10025, 'grpc2', E'{\n{\nPlugin = "grpc:my-gprc-plugin:arg1=val1:arg2"\n}\n}', 'x', now()),
(10026, 'noprefix', E'{\n{\nPlugin = "python:arg:module_name=noprefix"\n}\n}', 'x', now());
INSERT INTO Job (JobId, Job, Name, Type, Level, ClientId, JobStatus, JobBytes, ReadBytes, FilesetId) VALUES
(10000, 'file1-1',  'file1',    'B', 'F', 10000, 'T', 10^9, 1000, 10000),
(10001, 'file2-1',  'file2',    'B', 'F', 10000, 'T', 1000, 10^9, 10001),
(10002, 'file3-1',  'file3',    'B', 'F', 10000, 'T', 10^9, 1000, 10002),
(10003, 'file1-2',  'file1',    'B', 'F', 10000, 'T', 1000, 10^9, 10000),
(10004, 'barri1-1', 'barri1',   'B', 'F', 10001, 'T', 1000, 10^9, 10003),
(10005, 'barri2-1', 'barri2',   'B', 'F', 10001, 'T', 1000, 10^9, 10004),
(10006, 'barri1-2', 'barri1',   'B', 'F', 10001, 'T', 1000, 10^9, 10003),
(10007, 'barri2-2', 'barri2',   'B', 'F', 10001, 'T', 1000, 10^9, 10004),
(10008, 'file1-3',  'file1',    'B', 'F', 10002, 'T', 1000, 10^9, 10000),
(10009, 'file2-2',  'file2',    'B', 'F', 10002, 'T', 1000, 10^9, 10001),
(10010, 'bpipe1-1', 'bpipe1',   'B', 'F', 10002, 'T', 1000, 10^9, 10005),
(10011, 'barri1-3', 'barri1',   'B', 'F', 10002, 'T', 1000, 10^9, 10003),
(10012, 'bpipe1-2', 'bpipe1',   'B', 'F', 10003, 'T', 1000, 10^9, 10005),
(10013, 'barri1-4', 'barri1',   'B', 'F', 10003, 'T', 1000, 10^9, 10003),
(10014, 'barri2-3', 'barri2',   'B', 'F', 10003, 'T', 1000, 10^10, 10004),
(10015, 'hyper-v1', 'hyper-v1', 'B', 'F', 10003, 'T', 1000, 10^9, 10006),
(10016, 'hyper-v2', 'hyper-v2', 'B', 'F', 10003, 'T', 1000, 10^10, 10007),
(10017, 'hyper-v3', 'hyper-v3', 'B', 'F', 10003, 'T', 1000, 10^11, 10008),
(10018, 'mssql1',   'mssql1',   'B', 'F', 10003, 'T', 1000, 10^9, 10009),
(10019, 'mssql2',   'mssql2',   'B', 'F', 10003, 'T', 1000, 10^10, 10010),
(10020, 'mssql3',   'mssql3',   'B', 'F', 10003, 'T', 1000, 10^11, 10011),
(10021, 'mssql4',   'mssql4',   'B', 'F', 10003, 'T', 1000, 10^12, 10012),
(10022, 'ldap',     'ldap',     'B', 'F', 10004, 'T', 1000, 9 * 10^7, 10013),
(10023, 'libcloud', 'libcloud', 'B', 'F', 10004, 'T', 1000, 8 * 10^8, 10014),
(10024, 'mariadb',  'mariadb',  'B', 'F', 10004, 'T', 1000, 7 * 10^9, 10015),
(10025, 'ovirt',    'ovirt',    'B', 'F', 10004, 'T', 1000, 6 * 10^10, 10016),
(10026, 'percona',  'percona',  'B', 'F', 10004, 'T', 1000, 5 * 10^11, 10017),
(10027, 'postgres', 'postgres', 'B', 'F', 10004, 'T', 1000, 4 * 10^12, 10018),
(10028, 'proxmox',  'proxmox',  'B', 'F', 10004, 'T', 1000, 3 * 10^13, 10019),
(10029, 'qumulo',   'qumulo',   'B', 'F', 10004, 'T', 1000, 2 * 10^14, 10020),
(10030, 'vmware',   'vmware',   'B', 'F', 10004, 'T', 1000, 1 * 10^15, 10021),
(10031, 'nomod-py', 'nomod-py', 'B', 'F', 10005, 'T', 1000, 10^9, 10022),
(10032, 'custom',   'custom',   'B', 'F', 10005, 'T', 1000, 10^9, 10023),
(10033, 'grpc1',    'grpc1',    'B', 'F', 10005, 'T', 1000, 10^9, 10024),
(10034, 'grpc2',    'grpc2',    'B', 'F', 10005, 'T', 1000, 10^9, 10025),
(10035, 'noprefix', 'noprefix', 'B', 'F', 10005, 'T', 1000, 10^9, 10026);
CREATE TEMPORARY VIEW Job AS SELECT * FROM Job WHERE JobId >= 10000;

@$out $tmp/status-subscriptions-default.txt
status subscriptions
@$out $tmp/status-subscriptions-anon.txt
status subscriptions anonymize
@$out $tmp/status-subscriptions-client.txt
status subscriptions clients
@$out $tmp/status-subscriptions-client-anon.txt
status subscriptions clients anonymize
@$out $tmp/status-subscriptions-plugin.txt
status subscriptions plugins
@$out $tmp/status-subscriptions-client-detail.txt
status subscriptions client=file-and-plugins
@$out ${NULL_DEV}
.api 2
@$out $tmp/status-subscriptions-default.json
status subscriptions

@$out $tmp/status-subscriptions-anon.json
status subscriptions anonymize

@$out $tmp/status-subscriptions-client.json
status subscriptions clients

@$out $tmp/status-subscriptions-client-anon.json
status subscriptions clients anonymize

@$out $tmp/status-subscriptions-plugin.json
status subscriptions plugins

@$out $tmp/status-subscriptions-client-detail.json
status subscriptions client=file-and-plugins

@$out ${NULL_DEV}
.api 0

sqlquery sharedsession
UPDATE Job SET ReadBytes = 10^12 WHERE ReadBytes > 10^12;
SELECT * FROM Job;

@$out $tmp/status-subscriptions-countbased.txt
status subscriptions

@$out ${NULL_DEV}
.api 2
@$out $tmp/status-subscriptions-no-snapshot.json
status subscriptions all
@$out $tmp/status-subscriptions-legacy-baseline-client.json
status subscriptions client=file-only
@$out ${NULL_DEV}
.api 0

sqlquery sharedsession
SET TIME ZONE 'Etc/GMT+12';
INSERT INTO SubscriptionAccounting
  (ClientName, FileSetName, Excluded, Bytes)
SELECT DISTINCT c.Name, f.FileSet, false, 5000000000000
FROM Job j JOIN Client c USING (ClientId)
JOIN FileSet f USING (FileSetId);
UPDATE SubscriptionAccountingSnapshot
SET LastSuccess=CURRENT_TIMESTAMP, LastAttempt=CURRENT_TIMESTAMP,
    LastError=NULL WHERE SnapshotId=1;

@$out ${NULL_DEV}
.api 2
@$out $tmp/status-subscriptions-measured.json
status subscriptions all
@$out $tmp/status-subscriptions-measured-client.json
status subscriptions client=file-only
@$out $tmp/status-subscriptions-legacy.json
status subscriptions all legacy
@$out $tmp/status-subscriptions-legacy-client.json
status subscriptions client=file-only legacy
@$out $tmp/status-subscriptions-legacy-clients.json
status subscriptions clients legacy anonymize
@$out $tmp/status-subscriptions-legacy-plugins.json
status subscriptions plugins legacy
@$out $tmp/status-subscriptions-after-legacy.json
status subscriptions all
@$out ${NULL_DEV}
.api 0
@$out $tmp/status-subscriptions-invalid-legacy.txt
status subscriptions accounting legacy

sqlquery sharedsession
UPDATE SubscriptionAccounting SET Excluded=true
WHERE ClientName='file-only' AND FileSetName='file2';
DELETE FROM SubscriptionAccounting
WHERE ClientName='file-only' AND FileSetName='file3';
UPDATE SubscriptionAccountingSnapshot
SET LastSuccess=CURRENT_TIMESTAMP - INTERVAL '25 hours',
    LastError='simulated refresh failure' WHERE SnapshotId=1;

@$out $tmp/status-subscriptions-mixed.txt
status subscriptions
@$out ${NULL_DEV}
.api 2
@$out $tmp/status-subscriptions-mixed.json
status subscriptions all anonymize
@$out $tmp/status-subscriptions-accounting-stale.json
status subscriptions accounting
@$out ${NULL_DEV}
.api 0

sqlquery sharedsession
DELETE FROM SubscriptionAccounting WHERE ClientName IN
  (SELECT Name FROM Client WHERE ClientId >= 10000);
UPDATE SubscriptionAccountingSnapshot
SET LastSuccess=NULL, LastAttempt=NULL, LastError=NULL
WHERE SnapshotId=1;
RESET TIME ZONE;

@$out $tmp/status-subscriptions-cleanup.txt
sqlquery sharedsession
DROP VIEW Job;
DELETE FROM Job WHERE JobId >= 10000;
DELETE FROM Client WHERE ClientId >= 10000;
DELETE FROM Fileset WHERE FileSetId >= 10000;

END_OF_DATA
run_bconsole

"${PYTHON_EXECUTABLE}" - "$tmp" <<'PY'
import json
import pathlib
import sys

tmp = pathlib.Path(sys.argv[1])

def result(name):
    output = (tmp / f"status-subscriptions-{name}.json").read_text()
    return json.JSONDecoder().raw_decode(output[output.index("{"):])[0]["result"]

empty = result("no-snapshot")["subscription_accounting"]
assert empty["source"] == "estimated" and empty["estimated_combinations"] == 33
assert not empty["calculated_at"]
assert "age_seconds" not in empty
assert not empty["multiple_catalogs"]

measured = result("measured")
assert measured["subscription_accounting"]["source"] == "measured"
assert measured["subscription_accounting"]["estimated_combinations"] == 0
assert 0 <= measured["subscription_accounting"]["age_seconds"] < 60
assert not measured["subscription_accounting"]["stale"]
assert measured["unit-summary"]["used"] == "165"
assert next(row for row in measured["unit-clients"] if row["client"] == "file-only")["size_gb"] == "15000.00"

client = result("measured-client")
assert client["subscription_accounting"]["source"] == "measured"
assert client["subscription_accounting"]["combinations"] == 3
assert all(row["size_gb"] == "5000.00" for row in client["unit-detail"])

legacy = result("legacy")
assert legacy["subscription_accounting"]["source"] == "legacy"
assert legacy["subscription_accounting"]["estimated_combinations"] == 33
for key in ("unit-summary", "unit-detail", "unit-clients", "unit-plugins"):
    assert legacy[key] == result("no-snapshot")[key], key
assert legacy["unit-summary"] != measured["unit-summary"]
assert result("legacy-client")["unit-detail"] == result("legacy-baseline-client")["unit-detail"]
legacy_clients = result("legacy-clients")["unit-clients"]
assert all(row["client"].startswith("Client ") or row["client"] == "TOTAL" for row in legacy_clients)
assert sorted(row["size_gb"] for row in legacy_clients) == sorted(row["size_gb"] for row in legacy["unit-clients"])
assert result("legacy-plugins")["unit-plugins"] == legacy["unit-plugins"]
after_legacy = result("after-legacy")
assert after_legacy["unit-summary"] == measured["unit-summary"]
assert after_legacy["subscription_accounting"]["calculated_at"] == measured["subscription_accounting"]["calculated_at"]
assert after_legacy["subscription_accounting"]["source"] == "measured"
assert "cannot be combined" in (tmp / "status-subscriptions-invalid-legacy.txt").read_text()

mixed = result("mixed")
assert mixed["subscription_accounting"]["source"] == "mixed"
assert mixed["subscription_accounting"]["estimated_combinations"] == 2
assert mixed["subscription_accounting"]["stale"]
assert 90000 <= mixed["subscription_accounting"]["age_seconds"] < 90060
assert mixed["subscription_accounting"]["refresh_failed"]
assert mixed["unit-summary"]["used"] == "156"
assert next(row for row in mixed["unit-clients"] if row["client"] == "Client 10000")["size_gb"] == "5002.00"
assert "file-only" not in (tmp / "status-subscriptions-mixed.json").read_text()
assert measured["checksum"] != mixed["checksum"]

snapshot = result("accounting-stale")["accounting_snapshot"]
assert snapshot["stale"] and snapshot["last_refresh_error"] == "simulated refresh failure"

text = (tmp / "status-subscriptions-mixed.txt").read_text()
assert "job-based for 2 of 33" in text
assert "more than 24 hours old" in text
assert "latest subscription accounting refresh failed" in text.lower()
PY

# remove lines starting with @ so output is comparable
sed -i.bak -e '/^@/d' -e '/^Bareos version:/d' -e '/^Binary info:/d' -e '/^Report time:/d' -e '/^Checksum:/d' -e "/^Estimate only./d" -e '/^Subscription sizes are job-based for /d' -e '/^No subscription accounting snapshot is available;/d' "$tmp"/status-subscriptions-*.txt
sed -i.bak -e '/^@/d' -e '/"os":/d' -e '/"version":/d' -e '/"binary-info":/d' -e '/"report-time":/d' -e '/"checksum":/d' "$tmp"/status-subscriptions-*.json
"${PYTHON_EXECUTABLE}" - "$tmp" <<'PY'
import pathlib
import sys

tmp = pathlib.Path(sys.argv[1])
for path in tmp.glob("status-subscriptions-*.json"):
    if (pathlib.Path("expected") / path.name).exists():
        output = path.read_text()
        start = output.index('    "subscription_accounting": {')
        end = output.index("    },\n", start) + len("    },\n")
        path.write_text(output[:start] + output[end:])
PY

for f in expected/status-subscriptions-*.txt expected/status-subscriptions-*.json; do
  f="$(basename "$f")"
  if ! diff --ignore-all-space --unified "expected/$f" "$tmp/$f"; then
    echo "Output $f does not match expectation!" >&2
    estat=1
  fi
done

end_test
