from itertools import product
import json
import pytest
import re
from operator import attrgetter
from datetime import datetime, timezone, timedelta
from ...constants import *  # NOQA
from ...archiver.prune_cmd import (
    PRUNING_RULES,
    prune,
    PRUNE_DAILY,
    PRUNE_HOURLY,
    PRUNE_MINUTELY,
    PRUNE_MONTHLY,
    PRUNE_SECONDLY,
    PRUNE_WEEKLY,
    PRUNE_YEARLY,
    unique_period_func,
)
from ...helpers import GroupBySpec
from ...manifest import archive_group_key, format_group_key, group_archives
from argparse import ArgumentTypeError

from ...helpers import CommandError
from ...manifest import ArchiveInfo
from . import cmd, RK_ENCRYPTION, generate_archiver_tests

pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,binary")  # NOQA


def _create_archive_dt(archiver, backup_files, name, dt, tzinfo=timezone.utc):
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=tzinfo)
    cmd(archiver, "create", "--timestamp", dt.isoformat(timespec="microseconds"), name, backup_files)


def _create_archive_ts(archiver, backup_files, name, y, m, d, H=0, M=0, S=0, us=0, tzinfo=timezone.utc):
    _create_archive_dt(archiver, backup_files, name, datetime(y, m, d, H, M, S, us, tzinfo=tzinfo))


def prune_ungrouped(archiver, *args, **kwargs):
    """
    Run prune with grouping switched off.

    The tests below give each archive an own name so they can be told apart in the prune output,
    while testing the retention rules as if all of them belonged to one archive series. Grouping
    by name (the default) would put each of these archives into a group of its own, so these
    tests ask for a single group explicitly. Grouping itself is tested in test_prune_group_by_*.
    """
    return cmd(archiver, "prune", "--group-by", "", *args, **kwargs)


def test_prune_repository(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test1", backup_files)
    cmd(archiver, "create", "test2", backup_files)
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--keep-daily=1")
    assert re.search(r"Would prune:\s+test1", output)
    # Must keep the latest archive:
    assert re.search(r"Keeping archive \(rule: daily #1\):\s+test2", output)
    output = cmd(archiver, "repo-list")
    assert "test1" in output
    assert "test2" in output
    output = prune_ungrouped(archiver, "--list", "--keep-daily=1")
    assert re.search(r"Pruning archive \(1/1\):\s+test1", output)
    output = cmd(archiver, "repo-list")
    assert "test1" not in output
    # The latest archive must still be there:
    assert "test2" in output


# This test must match docs/misc/prune-example.txt
def test_prune_repository_example(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    # Archives that will be kept, per the example
    # Oldest archive
    _create_archive_ts(archiver, backup_files, "test01", 2015, 1, 1)
    # 6 monthly archives
    _create_archive_ts(archiver, backup_files, "test02", 2015, 6, 30)
    _create_archive_ts(archiver, backup_files, "test03", 2015, 7, 31)
    _create_archive_ts(archiver, backup_files, "test04", 2015, 8, 31)
    _create_archive_ts(archiver, backup_files, "test05", 2015, 9, 30)
    _create_archive_ts(archiver, backup_files, "test06", 2015, 10, 31)
    _create_archive_ts(archiver, backup_files, "test07", 2015, 11, 30)
    # 14 daily archives
    _create_archive_ts(archiver, backup_files, "test08", 2015, 12, 17)
    _create_archive_ts(archiver, backup_files, "test09", 2015, 12, 18)
    _create_archive_ts(archiver, backup_files, "test10", 2015, 12, 20)
    _create_archive_ts(archiver, backup_files, "test11", 2015, 12, 21)
    _create_archive_ts(archiver, backup_files, "test12", 2015, 12, 22)
    _create_archive_ts(archiver, backup_files, "test13", 2015, 12, 23)
    _create_archive_ts(archiver, backup_files, "test14", 2015, 12, 24)
    _create_archive_ts(archiver, backup_files, "test15", 2015, 12, 25)
    _create_archive_ts(archiver, backup_files, "test16", 2015, 12, 26)
    _create_archive_ts(archiver, backup_files, "test17", 2015, 12, 27)
    _create_archive_ts(archiver, backup_files, "test18", 2015, 12, 28)
    _create_archive_ts(archiver, backup_files, "test19", 2015, 12, 29)
    _create_archive_ts(archiver, backup_files, "test20", 2015, 12, 30)
    _create_archive_ts(archiver, backup_files, "test21", 2015, 12, 31)
    # Additional archives that would be pruned
    # The second backup of the year
    _create_archive_ts(archiver, backup_files, "test22", 2015, 1, 2)
    # The next older monthly backup
    _create_archive_ts(archiver, backup_files, "test23", 2015, 5, 31)
    # The next older daily backup
    _create_archive_ts(archiver, backup_files, "test24", 2015, 12, 16)
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--keep-daily=14", "--keep-monthly=6", "--keep-yearly=1")
    # Prune second backup of the year
    assert re.search(r"Would prune:\s+test22", output)
    # Prune next older monthly and daily backups
    assert re.search(r"Would prune:\s+test23", output)
    assert re.search(r"Would prune:\s+test24", output)
    # Must keep the other 21 backups
    # Yearly is kept as oldest archive
    assert re.search(r"Keeping archive \(rule: yearly\[oldest\] #1\):\s+test01", output)
    for i in range(1, 7):
        assert re.search(r"Keeping archive \(rule: monthly #" + str(i) + r"\):\s+test" + ("%02d" % (8 - i)), output)
    for i in range(1, 15):
        assert re.search(r"Keeping archive \(rule: daily #" + str(i) + r"\):\s+test" + ("%02d" % (22 - i)), output)
    output = cmd(archiver, "repo-list")
    # Nothing pruned after dry run
    for i in range(1, 25):
        assert "test%02d" % i in output
    prune_ungrouped(archiver, "--keep-daily=14", "--keep-monthly=6", "--keep-yearly=1")
    output = cmd(archiver, "repo-list")
    # All matching backups plus oldest kept
    for i in range(1, 22):
        assert "test%02d" % i in output
    # Other backups have been pruned
    for i in range(22, 25):
        assert "test%02d" % i not in output


# This test must match docs/misc/prune-example-interval.txt
def test_prune_repository_example_interval(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)

    # All timestamps are at exactly 16:00 UTC (matching the example).
    # Backups on most days from 2025-11-15 to 2026-06-04, with skips on
    # 2026-03-31, 2026-05-24, and 2026-06-03.
    archive_dates = [
        (2025, 11, 15),
        (2025, 12, 31),
        (2026, 1, 31),
        (2026, 2, 28),
        (2026, 3, 30),
        (2026, 4, 30),
        (2026, 5, 3),
        (2026, 5, 10),
        (2026, 5, 17),
        (2026, 5, 23),
        (2026, 5, 27),
        (2026, 5, 28),
        (2026, 5, 29),
        (2026, 5, 30),
        (2026, 5, 31),
        (2026, 6, 1),
        (2026, 6, 2),
        (2026, 6, 4),
    ]

    names = [f"backup_{y:04d}-{m:02d}-{d:02d}" for y, m, d in archive_dates]
    for (y, m, d), name in zip(archive_dates, names):
        _create_archive_ts(archiver, backup_files, name, y, m, d, H=16)

    output = prune_ungrouped(
        archiver,
        "--list",
        "--dry-run",
        "--from=2026-06-04T16:00:00+00:00",
        "--keep-daily=1w",
        "--keep-weekly=4",
        "--keep-monthly=5m",
        "--keep-yearly=2",
    )

    # 2026-06-04 is kept unconditionally by the --from prefilter.
    assert re.search(r"Keeping archive \(rule: skip #1\):\s+backup_2026-06-04", output)

    daily_kept = [
        "backup_2026-06-02",
        "backup_2026-06-01",
        "backup_2026-05-31",
        "backup_2026-05-30",
        "backup_2026-05-29",
        "backup_2026-05-28",
    ]
    for i, name in enumerate(daily_kept, 1):
        assert re.search(rf"Keeping archive \(rule: daily #{i}\):\s+{name}", output)

    # Weekly W22 slot is consumed by 05-31 (already kept by daily),
    # so weekly reaches back to W18 to fill all 4 slots.
    weekly_kept = [
        "backup_2026-05-23",  # W21 — no Sunday candidate (05-24 skipped)
        "backup_2026-05-17",  # W20
        "backup_2026-05-10",  # W19
        "backup_2026-05-03",  # W18
    ]
    for i, name in enumerate(weekly_kept, 1):
        assert re.search(rf"Keeping archive \(rule: weekly #{i}\):\s+{name}", output)

    monthly_kept = [
        "backup_2026-04-30",
        "backup_2026-03-30",
        "backup_2026-02-28",
        "backup_2026-01-31",
        "backup_2025-12-31",
    ]
    for i, name in enumerate(monthly_kept, 1):
        assert re.search(rf"Keeping archive \(rule: monthly #{i}\):\s+{name}", output)

    # No true yearly candidates remain; only the oldest archive fills the slot.
    assert re.search(r"Keeping archive \(rule: yearly\[oldest\] #1\):\s+backup_2025-11-15", output)

    # 05-27 was kept by daily yesterday (window started 05-26) but falls out
    # today (window shifted to 05-28). W22 slot is consumed by daily-held 05-31,
    # so weekly doesn't save it either.
    assert re.search(r"Would prune:\s+backup_2026-05-27", output)


def test_prune_quarterly(archivers, request, backup_files):
    # Example worked through by hand when developing the quarterly
    # strategy, based on existing backups where the quarterly strategy
    # is desired. Weekly/monthly backups that do not affect results were
    # trimmed to speed up the test.
    #
    # The ISO week number is shown in a comment for each row in the list below.
    # The year is also shown when it does not match the year given in the
    # date tuple.
    archiver = request.getfixturevalue(archivers)
    test_dates = [
        (2020, 12, 6),
        (2021, 1, 3),  # 49, 2020-53
        (2021, 3, 28),
        (2021, 4, 25),  # 12, 16
        (2021, 6, 27),
        (2021, 7, 4),  # 25, 26
        (2021, 9, 26),
        (2021, 10, 3),  # 38, 39
        (2021, 12, 26),
        (2022, 1, 2),  # 51, 2021-52
    ]

    def mk_name(tup):
        (y, m, d) = tup
        suff = datetime(y, m, d).strftime("%Y-%m-%d")
        return f"test-{suff}"

    # The kept repos are based on working on an example by hand,
    # archives made on the following dates should be kept:
    EXPECTED_KEPT = {
        "13weekly": [(2020, 12, 6), (2021, 1, 3), (2021, 3, 28), (2021, 7, 4), (2021, 10, 3), (2022, 1, 2)],
        "3monthly": [(2020, 12, 6), (2021, 3, 28), (2021, 6, 27), (2021, 9, 26), (2021, 12, 26), (2022, 1, 2)],
    }

    for strat, to_keep in EXPECTED_KEPT.items():
        # Initialize our repo.
        cmd(archiver, "repo-create", RK_ENCRYPTION)
        for a, (y, m, d) in zip(map(mk_name, test_dates), test_dates):
            _create_archive_ts(archiver, backup_files, a, y, m, d)

        to_prune = list(set(test_dates) - set(to_keep))

        # Use 99 instead of -1 to test that oldest backup is kept.
        output = prune_ungrouped(archiver, "--list", "--dry-run", f"--keep-{strat}=99")
        for a in map(mk_name, to_prune):
            assert re.search(rf"Would prune:\s+{a}", output)

        oldest = r"\[oldest\]" if strat in ("13weekly") else ""
        assert re.search(rf"Keeping archive \(rule: quarterly_{strat}{oldest} #\d+\):\s+test-2020-12-06", output)
        for a in map(mk_name, to_keep[1:]):
            assert re.search(rf"Keeping archive \(rule: quarterly_{strat} #\d+\):\s+{a}", output)

        output = cmd(archiver, "repo-list")
        # Nothing pruned after dry run
        for a in map(mk_name, test_dates):
            assert a in output

        prune_ungrouped(archiver, f"--keep-{strat}=99")
        output = cmd(archiver, "repo-list")
        # All matching backups plus oldest kept
        for a in map(mk_name, to_keep):
            assert a in output
        # Other backups have been pruned
        for a in map(mk_name, to_prune):
            assert a not in output

        # Delete repo and begin anew
        cmd(archiver, "repo-delete")


# With an initial and daily backup, prune daily until oldest is replaced by a monthly backup
def test_prune_retain_and_expire_oldest(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    # Initial backup
    _create_archive_ts(archiver, backup_files, "original_archive", 2020, 9, 1, 11, 15)
    # Archive and prune daily for 30 days
    for i in range(1, 31):
        _create_archive_ts(archiver, backup_files, "september%02d" % i, 2020, 9, i, 12)
        prune_ungrouped(archiver, "--keep-daily=7", "--keep-monthly=1")
    # Archive and prune 6 days into the next month
    for i in range(1, 7):
        _create_archive_ts(archiver, backup_files, "october%02d" % i, 2020, 10, i, 12)
        prune_ungrouped(archiver, "--keep-daily=7", "--keep-monthly=1")
    # Oldest backup is still retained
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--keep-daily=7", "--keep-monthly=1")
    assert re.search(r"Keeping archive \(rule: monthly\[oldest\] #1" + r"\):\s+original_archive", output)
    # Archive one more day and prune.
    _create_archive_ts(archiver, backup_files, "october07", 2020, 10, 7, 12)
    prune_ungrouped(archiver, "--keep-daily=7", "--keep-monthly=1")
    # Last day of previous month is retained as monthly, and oldest is expired.
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--keep-daily=7", "--keep-monthly=1")
    assert re.search(r"Keeping archive \(rule: monthly #1\):\s+september30", output)
    assert "original_archive" not in output


def test_prune_repository_prefix(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "foo-2015-08-12-10:00", backup_files)
    cmd(archiver, "create", "foo-2015-08-12-20:00", backup_files)
    cmd(archiver, "create", "bar-2015-08-12-10:00", backup_files)
    cmd(archiver, "create", "bar-2015-08-12-20:00", backup_files)
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--keep-daily=1", "--match-archives=sh:foo-*")
    assert re.search(r"Keeping archive \(rule: daily #1\):\s+foo-2015-08-12-20:00", output)
    assert re.search(r"Would prune:\s+foo-2015-08-12-10:00", output)
    output = cmd(archiver, "repo-list")
    assert "foo-2015-08-12-10:00" in output
    assert "foo-2015-08-12-20:00" in output
    assert "bar-2015-08-12-10:00" in output
    assert "bar-2015-08-12-20:00" in output
    prune_ungrouped(archiver, "--keep-daily=1", "--match-archives=sh:foo-*")
    output = cmd(archiver, "repo-list")
    assert "foo-2015-08-12-10:00" not in output
    assert "foo-2015-08-12-20:00" in output
    assert "bar-2015-08-12-10:00" in output
    assert "bar-2015-08-12-20:00" in output


def test_prune_repository_glob(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "2015-08-12-10:00-foo", backup_files)
    cmd(archiver, "create", "2015-08-12-20:00-foo", backup_files)
    cmd(archiver, "create", "2015-08-12-10:00-bar", backup_files)
    cmd(archiver, "create", "2015-08-12-20:00-bar", backup_files)
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--keep-daily=1", "--match-archives=sh:2015-*-foo")
    assert re.search(r"Keeping archive \(rule: daily #1\):\s+2015-08-12-20:00-foo", output)
    assert re.search(r"Would prune:\s+2015-08-12-10:00-foo", output)
    output = cmd(archiver, "repo-list")
    assert "2015-08-12-10:00-foo" in output
    assert "2015-08-12-20:00-foo" in output
    assert "2015-08-12-10:00-bar" in output
    assert "2015-08-12-20:00-bar" in output
    prune_ungrouped(archiver, "--keep-daily=1", "--match-archives=sh:2015-*-foo")
    output = cmd(archiver, "repo-list")
    assert "2015-08-12-10:00-foo" not in output
    assert "2015-08-12-20:00-foo" in output
    assert "2015-08-12-10:00-bar" in output
    assert "2015-08-12-20:00-bar" in output


def test_prune_ignore_protected(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "archive1", archiver.input_path)
    cmd(archiver, "tag", "--set=@PROT", "archive1")  # do not delete archive1!
    cmd(archiver, "create", "archive2", archiver.input_path)
    cmd(archiver, "create", "archive3", archiver.input_path)
    output = prune_ungrouped(archiver, "--list", "--keep=1", "--match-archives=sh:archive*")
    assert "archive1" not in output  # @PROT archives are completely ignored.
    assert re.search(r"Keeping archive \(rule: keep #1\):\s+archive3", output)
    assert re.search(r"Pruning archive \(.*?\):\s+archive2", output)
    output = cmd(archiver, "repo-list")
    assert "archive1" in output  # @PROT protected archive1 from deletion
    assert "archive3" in output  # last one


mock_id = 0


def mock_archive(ts, id=None):
    """Create an ArchiveInfo with mocked/default values."""
    global mock_id
    if id is None:
        id = mock_id
        mock_id += 1
    return ArchiveInfo(name="", id=id, ts=ts.replace(tzinfo=timezone.utc), tags=(), host="", user="")


@pytest.mark.parametrize(
    "rule,num_to_keep,expected_indices",
    [
        (PRUNE_YEARLY, 3, (12, 1, 0)),
        (PRUNE_MONTHLY, 3, (12, 7, 3)),
        (PRUNE_WEEKLY, 2, (12, 7)),
        (PRUNE_DAILY, 3, (12, 7, 6)),
        (PRUNE_HOURLY, 3, (12, 9, 7)),
        (PRUNE_MINUTELY, 3, (12, 9, 8)),
        (PRUNE_SECONDLY, 4, (12, 11, 10, 9)),
        (PRUNE_DAILY, 0, []),
        (PRUNE_DAILY, -1, (12, 7, 6, 5, 4, 3, 2, 1, 0)),
    ],
)
def test_prune(rule, num_to_keep, expected_indices):
    archives = [
        # years apart
        mock_archive(datetime(2015, 1, 1, 10, 0, 0)),
        mock_archive(datetime(2016, 1, 1, 10, 0, 0)),
        mock_archive(datetime(2017, 1, 1, 10, 0, 0)),
        # months apart
        mock_archive(datetime(2017, 2, 1, 10, 0, 0)),
        mock_archive(datetime(2017, 3, 1, 10, 0, 0)),
        # days apart
        mock_archive(datetime(2017, 3, 2, 10, 0, 0)),
        mock_archive(datetime(2017, 3, 3, 10, 0, 0)),
        mock_archive(datetime(2017, 3, 4, 10, 0, 0)),
        # minutes apart
        mock_archive(datetime(2017, 10, 1, 9, 45, 0)),
        mock_archive(datetime(2017, 10, 1, 9, 55, 0)),
        # seconds apart
        mock_archive(datetime(2017, 10, 1, 10, 0, 1)),
        mock_archive(datetime(2017, 10, 1, 10, 0, 3)),
        mock_archive(datetime(2017, 10, 1, 10, 0, 5)),
    ]
    keep = prune(sorted(archives, key=attrgetter("ts"), reverse=True), rule, num_to_keep, None, False)

    assert set(keep) == {archives[i] for i in expected_indices}
    assert all(result.rule == rule for _, result in keep.items())


def test_prune_keep_oldest():
    archives = [
        # oldest backup, but not last in its year
        mock_archive(datetime(2018, 1, 1, 10, 0, 0)),
        # an interim backup
        mock_archive(datetime(2018, 12, 30, 10, 0, 0)),
        # year-end backups
        mock_archive(datetime(2018, 12, 31, 10, 0, 0)),
        mock_archive(datetime(2019, 12, 31, 10, 0, 0)),
    ]
    sorted_archives = sorted(archives, key=attrgetter("ts"), reverse=True)

    # Keep oldest when retention target can't otherwise be met
    keep = prune(sorted_archives, PRUNE_YEARLY, 3, None, True)

    assert keep[archives[0]].rule.key == "yearly" and keep[archives[0]].oldest is True
    assert keep[archives[2]].rule.key == "yearly" and keep[archives[2]].oldest is False
    assert keep[archives[3]].rule.key == "yearly" and keep[archives[3]].oldest is False
    assert len(keep) == 3

    # Otherwise, prune it
    keep = prune(sorted_archives, PRUNE_YEARLY, 2, None, True)

    assert keep[archives[2]].rule.key == "yearly" and keep[archives[2]].oldest is False
    assert keep[archives[3]].rule.key == "yearly" and keep[archives[3]].oldest is False
    assert len(keep) == 2


def test_prune_no_archives():
    archives = []

    keep = prune(archives, PRUNE_YEARLY, 3, None, False)

    assert keep == {}


def test_prune_list_with_metadata_format(archivers, request, backup_files):
    # Regression test for: prune --list with a format string that requires loading
    # archive metadata (e.g. {hostname}) must not fail when archives are deleted.
    # The bug was that format_item() was called after archive.delete(), causing
    # Archive.DoesNotExist when the formatter tried to lazy-load the archive.
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test1", backup_files)
    cmd(archiver, "create", "test2", backup_files)
    # {hostname} is a "call key" that triggers lazy loading of the archive from the repo.
    # With the buggy code this would raise Archive.DoesNotExist for the pruned archive.
    output = prune_ungrouped(archiver, "--list", "--keep-daily=1", "--format={name} {hostname}{NL}")
    assert "test1" in output
    assert "test2" in output


def test_prune_json(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test1", backup_files)
    cmd(archiver, "create", "test2", backup_files)
    prune_result = json.loads(prune_ungrouped(archiver, "--json", "--dry-run", "--keep-daily=1"))
    assert "repository" in prune_result
    assert "encryption" in prune_result
    assert len(prune_result["repository"]["id"]) == 64
    archives = prune_result["archives"]
    assert len(archives) == 2
    kept = [a for a in archives if a["kept"]]
    pruned = [a for a in archives if not a["kept"]]
    assert len(kept) == 1
    assert len(pruned) == 1
    assert kept[0]["name"] == "test2"
    assert kept[0]["keep_rule"] == "daily"
    assert kept[0]["kept_archive_number"] == 1
    assert not kept[0]["kept_oldest"]
    assert "deleted_archive_number" not in kept[0]
    assert pruned[0]["name"] == "test1"
    assert pruned[0]["deleted_archive_number"] == 1
    assert "keep_rule" not in pruned[0]
    assert "kept_archive_number" not in pruned[0]
    for archive in archives:
        assert "name" in archive
        assert "id" in archive
        assert "time" in archive
        assert "kept" in archive


def test_prune_json_list_pruned(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test1", backup_files)
    cmd(archiver, "create", "test2", backup_files)
    prune_result = json.loads(prune_ungrouped(archiver, "--json", "--dry-run", "--list-pruned", "--keep-daily=1"))
    archives = prune_result["archives"]
    assert len(archives) == 1
    assert archives[0]["name"] == "test1"
    assert archives[0]["kept"] is False
    assert archives[0]["deleted_archive_number"] == 1


def test_prune_keep_same_second(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    cmd(archiver, "create", "test1", backup_files)
    cmd(archiver, "create", "test2", backup_files)
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--keep=2")
    # Both archives are kept even though they have the same timestamp to the second. Would previously have failed with
    # old behavior of --keep-last. Archives sorted on seconds, order is undefined.
    assert re.search(r"Keeping archive \(rule: keep #\d\):\s+test1", output)
    assert re.search(r"Keeping archive \(rule: keep #\d\):\s+test2", output)


@pytest.mark.parametrize("keep_arg", ["--keep=2", "--keep=1S"])
def test_prune_keep_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
    # Two archives at the --from since boundary prove the inclusive timestamp check.
    _create_archive_dt(archiver, backup_files, "test-1", dt)
    _create_archive_dt(
        archiver, backup_files, "test-2", dt - timedelta(microseconds=999999)
    )  # Would be pruned if `secondly`-rule was active.
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(seconds=1))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(seconds=1, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: skip #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: keep #1\):\s+test-2", output)
    assert re.search(r"Keeping archive \(rule: keep #2\):\s+test-3", output)
    assert re.search(r"Would prune:\s+test-4", output)


@pytest.mark.parametrize("keep_arg", ["--keep-secondly=2", "--keep-secondly=2S"])
def test_prune_keep_secondly_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(seconds=1))
    _create_archive_dt(archiver, backup_files, "test-2", dt - timedelta(seconds=1, microseconds=999999))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(seconds=2))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(seconds=2, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: secondly #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: secondly #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Would prune:\s+test-4", output)


@pytest.mark.parametrize("keep_arg", ["--keep-minutely=3", "--keep-minutely=3M"])
def test_prune_keep_minutely_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 23, 59, 0, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(minutes=1))
    _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(minutes=2)).replace(second=1))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(minutes=2))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(minutes=3))
    _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(minutes=3, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: minutely #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: minutely #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: minutely #3\):\s+test-4", output)
    assert re.search(r"Would prune:\s+test-5", output)


@pytest.mark.parametrize("keep_arg", ["--keep-hourly=3", "--keep-hourly=3H"])
def test_prune_keep_hourly_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 23, 0, 0, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(hours=1))
    _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(hours=2)).replace(microsecond=1))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(hours=2))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(hours=3))
    _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(hours=3, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: hourly #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: hourly #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: hourly #3\):\s+test-4", output)
    assert re.search(r"Would prune:\s+test-5", output)


@pytest.mark.parametrize("keep_arg", ["--keep-daily=3", "--keep-daily=3d"])
def test_prune_keep_daily_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(days=1))
    _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(days=2)).replace(second=1))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=2))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(days=3))
    _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(days=3, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: daily #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: daily #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: daily #3\):\s+test-4", output)
    assert re.search(r"Would prune:\s+test-5", output)


@pytest.mark.parametrize("keep_arg", ["--keep-weekly=3", "--keep-weekly=3w"])
def test_prune_keep_weekly_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(days=7))
    _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(days=14)).replace(microsecond=1))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=14))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(days=21))
    _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(days=21, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: weekly #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: weekly #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: weekly #3\):\s+test-4", output)
    assert re.search(r"Would prune:\s+test-5", output)


@pytest.mark.parametrize("keep_arg", ["--keep-monthly=3", "--keep-monthly=3m"])
def test_prune_keep_monthly_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(days=31))
    _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(days=62)).replace(microsecond=1))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=62))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(days=93))
    _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(days=93, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: monthly #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: monthly #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: monthly #3\):\s+test-4", output)
    assert re.search(r"Would prune:\s+test-5", output)


# 2023-12-31 is Sunday, week 52. Makes these week calculations a little easier.
@pytest.mark.parametrize("keep_arg", ["--keep-13weekly=3", "--keep-13weekly=39w"])
def test_prune_keep_13weekly_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(days=91))
    _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(days=182)).replace(microsecond=1))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=182))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(days=273))
    _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(days=273, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: quarterly_13weekly #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: quarterly_13weekly #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: quarterly_13weekly #3\):\s+test-4", output)
    assert re.search(r"Would prune:\s+test-5", output)


@pytest.mark.parametrize("keep_arg", ["--keep-3monthly=3", "--keep-3monthly=275d"])
def test_prune_keep_3monthly_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(days=92))
    _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(days=184)).replace(microsecond=1))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=184))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(days=275))
    _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(days=275, microseconds=1))
    # 275d is the interval from dt to the oldest kept monthly archive
    output = prune_ungrouped(archiver, "--list", "--short", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: quarterly_3monthly #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: quarterly_3monthly #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: quarterly_3monthly #3\):\s+test-4", output)
    assert re.search(r"Would prune:\s+test-5", output)


@pytest.mark.parametrize("keep_arg", ["--keep-yearly=3", "--keep-yearly=3y"])
def test_prune_keep_yearly_int_or_interval(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 0, 0, 0, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(days=365))
    _create_archive_dt(archiver, backup_files, "test-2", (dt - timedelta(days=730)).replace(microsecond=1))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=730))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(days=1095))
    _create_archive_dt(archiver, backup_files, "test-5", dt - timedelta(days=1095, microseconds=1))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: yearly #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: yearly #2\):\s+test-2", output)
    assert re.search(r"Would prune:\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: yearly #3\):\s+test-4", output)
    assert re.search(r"Would prune:\s+test-5", output)


@pytest.mark.parametrize("keep_arg", ["--keep-daily=-1", "--keep-daily=all"])
def test_prune_keep_daily_all(archivers, request, backup_files, keep_arg):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(days=1))
    _create_archive_dt(archiver, backup_files, "test-2", dt - timedelta(days=2))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=3))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: daily #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: daily #2\):\s+test-2", output)
    assert re.search(r"Keeping archive \(rule: daily #3\):\s+test-3", output)


@pytest.mark.parametrize("keep_arg", ["--keep=-1", "--keep=all"])
def test_prune_keep_flat_all(archivers, request, backup_files, keep_arg):
    """--keep=all / --keep=-1 keeps every archive."""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2023, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
    _create_archive_dt(archiver, backup_files, "test-1", dt - timedelta(microseconds=1))
    _create_archive_dt(archiver, backup_files, "test-2", dt - timedelta(microseconds=2))
    _create_archive_dt(archiver, backup_files, "test-3", dt - timedelta(days=3))
    _create_archive_dt(archiver, backup_files, "test-4", dt - timedelta(days=3333))
    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), keep_arg)
    assert re.search(r"Keeping archive \(rule: keep #1\):\s+test-1", output)
    assert re.search(r"Keeping archive \(rule: keep #2\):\s+test-2", output)
    assert re.search(r"Keeping archive \(rule: keep #3\):\s+test-3", output)
    assert re.search(r"Keeping archive \(rule: keep #4\):\s+test-4", output)


def _cmd_prune_error(archiver, *args):
    """Run prune expecting a CommandError. Returns error string for assertions."""
    if archiver.FORK_DEFAULT:
        return cmd(archiver, "prune", *args, exit_code=CommandError().exit_code)
    with pytest.raises(CommandError) as error:
        cmd(archiver, "prune", *args)
    return str(error.value)


def test_prune_no_args(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    output = _cmd_prune_error(archiver)

    assert re.search(r"At least one of the .* settings must be specified.", output)
    assert re.search(r"keep(?!-)", output)
    flags = ["secondly", "minutely", "hourly", "daily", "weekly", "monthly", "yearly", "13weekly", "3monthly"]
    for flag in flags:
        assert f"keep-{flag}" in output


@pytest.mark.parametrize("keep_arg,value", list(product([rule.key for rule in PRUNING_RULES], ["0", "0S"])))
def test_prune_all_zero_args_one(archivers, request, keep_arg, value):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)

    arg_with_prefix = "--keep" if keep_arg == "keep" else f"--keep-{keep_arg.replace('quarterly_', '')}"
    output = _cmd_prune_error(archiver, f"{arg_with_prefix}={value}")
    assert re.search(r"None of the .* settings have a positive value. At least one must be non-zero.", output)


def test_prune_all_zero_multiple_multiple(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)

    output = _cmd_prune_error(archiver, "--keep-secondly=0S", "--keep-daily=0")
    assert re.search(r"None of the .* settings have a positive value. At least one must be non-zero.", output)


@pytest.mark.parametrize(
    "lo_val,hi_val",
    [("14d", "7d"), ("-1", "7d"), ("-1", "1"), ("-1", "-1"), ("all", "7d"), ("all", "1"), ("all", "-1")],
)
def test_prune_warns_on_redundant_interval_flags(archivers, request, lo_val, hi_val):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    output = _cmd_prune_error(archiver, "--dry-run", f"--keep-hourly={lo_val}", f"--keep-daily={hi_val}")
    assert "hourly=" in output
    assert "daily=" in output
    assert "effectively useless" in output


@pytest.mark.parametrize("lo_val,hi_val", [("7d", "14d"), ("7d", "-1"), ("1", "-1"), ("7d", "all"), ("1", "all")])
def test_prune_does_not_warn_on_normal_interval_flags(archivers, request, lo_val, hi_val):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    output = cmd(archiver, "prune", "--dry-run", f"--keep-hourly={lo_val}", f"--keep-daily={hi_val}")
    assert "effectively useless" not in output


def test_prune_int_rolling_schedule_oldest_retention():
    daily_n = 6
    monthly_n = 3
    start_date = datetime(2024, 1, 1, tzinfo=timezone.utc)

    previous_archives = []
    archives = []

    for day_offset in range(97):
        backup_ts = start_date + timedelta(days=day_offset)
        previous_archives = archives
        archives = [mock_archive(backup_ts, day_offset), *archives]

        keep = {}
        keep |= prune(archives, PRUNE_DAILY, daily_n, None, False, keep)
        keep |= prune(archives, PRUNE_MONTHLY, monthly_n, None, True, keep)

        archives = sorted(keep.keys(), key=lambda a: a.ts, reverse=True)

    # It is now 2024-04-06. The last run should have just pruned the jan-01
    # archive since the monthly retention count is now satisfied at jan-31. It
    # was kept until now to satisfy the oldest-rule.

    assert previous_archives[-1].ts.strftime("%m-%d") == "01-01"
    assert archives[-1].ts.strftime("%m-%d") == "01-31"


def test_prune_from_prefiltered_archives_ignored_in_pruning(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    dt = datetime(2024, 6, 6, 12, 0, 0, tzinfo=timezone.utc)

    _create_archive_dt(archiver, backup_files, "test-a", dt + timedelta(hours=1))
    _create_archive_dt(archiver, backup_files, "test-b", dt - timedelta(hours=1))
    _create_archive_dt(archiver, backup_files, "test-c", dt - timedelta(days=1))

    output = prune_ungrouped(archiver, "--list", "--dry-run", "--from", dt.isoformat(), "--keep-daily=1")

    # 'test-b' is kept, meaning 'test-a' was entirely skipped for pruning consideration.
    # They would otherwise have occupied the same period.
    assert re.search(r"Keeping archive \(rule: skip #1\):\s+test-a", output)
    assert re.search(r"Keeping archive \(rule: daily #1\):\s+test-b", output)

    assert re.search(r"Would prune:\s+test-c", output)


def test_prune_interval_rolling_schedule_oldest_retention():
    daily_interval = timedelta(days=6)
    monthly_interval = timedelta(days=31 * 3)  # Matching --keep-monthly=3m after argument parsing
    start_date = datetime(2024, 1, 1, tzinfo=timezone.utc)

    previous_archives = []
    archives = []

    for day_offset in range(95):
        backup_ts = start_date + timedelta(days=day_offset)
        previous_archives = archives
        archives = [mock_archive(backup_ts, day_offset), *archives]

        keep = {}
        keep |= prune(archives, PRUNE_DAILY, daily_interval, backup_ts, False, keep)
        keep |= prune(archives, PRUNE_MONTHLY, monthly_interval, backup_ts, True, keep)

        print(
            f"For backup+prune at {backup_ts.strftime('%m-%d')} ({day_offset})"
            f" the following {len(archives)} archives are kept:"
        )
        for a, result in keep.items():
            print(f"    {a.id}: {a.ts.strftime('%Y-%m-%d')} {result}")

        archives = sorted(keep.keys(), key=lambda a: a.ts, reverse=True)

    # It is now 2024-04-04. On this final run jan-01 falls outside the
    # 93d monthly interval and is pruned. It was kept until now by the
    # oldest-retention rule — on 2024-04-03 it was exactly at the
    # inclusive boundary and retained as oldest.

    assert previous_archives[-1].ts.strftime("%m-%d") == "01-01"
    assert archives[-1].ts.strftime("%m-%d") == "01-31"


def test_prune_name_and_match_archives_are_combined(archivers, request, backup_files, monkeypatch):
    """In a shared repository, pruning a series must be restrictable to the archives of one host."""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    for host in ("host1", "host2"):
        monkeypatch.setenv("BORG_HOSTNAME", host)
        _create_archive_ts(archiver, backup_files, "home", 2024, 1, 1)
        _create_archive_ts(archiver, backup_files, "home", 2024, 1, 2)
    monkeypatch.delenv("BORG_HOSTNAME")
    # NAME is ANDed with -a / --match-archives, so only host1's "home" archives are candidates.
    cmd(archiver, "prune", "home", "-a", "host:host1", "--keep-daily=1")
    output = cmd(archiver, "repo-list", "--format={hostname}{NL}")
    assert output.count("host1") == 1  # only the most recent one is left
    assert output.count("host2") == 2  # host2 was not touched at all


@pytest.mark.parametrize("rule", PRUNING_RULES, ids=[rule.key for rule in PRUNING_RULES])
def test_period_func_is_created_per_prune_invocation(rule):
    """Period grouping functions are per prune() invocation, they must not share state."""
    dt = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
    first, second = rule.make_period_func(), rule.make_period_func()
    assert first is not second
    # a fresh period grouping function starts over, it does not continue where another one left off:
    assert [first(dt) for _ in range(3)] == [second(dt) for _ in range(3)]


def test_unique_period_values_are_padded_and_ordered():
    """The counter of a single period grouping function stays within its zero padding."""
    dt = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
    period_func = unique_period_func()
    values = [period_func(dt) for _ in range(1000)]
    assert len(set(values)) == len(values)  # each archive lands in a period of its own
    assert len({len(value) for value in values}) == 1  # uniform width, so ...
    assert values == sorted(values)  # ... lexicographic ordering matches numeric ordering


def _grouped_archive(name="home", host="myhost", user="myuser", tags=()):
    return ArchiveInfo(name=name, id=b"", ts=datetime(2024, 1, 1, tzinfo=timezone.utc), tags=tags, host=host, user=user)


def test_archive_group_key():
    archive_info = _grouped_archive(name="home", host="myhost", user="myuser", tags=("b", "a"))
    assert archive_group_key(archive_info, ()) == ()
    assert archive_group_key(archive_info, ("name",)) == ("home",)
    assert archive_group_key(archive_info, ("name", "host", "user")) == ("home", "myhost", "myuser")
    assert archive_group_key(archive_info, ("host", "name")) == ("myhost", "home")  # order matters
    assert archive_group_key(archive_info, ("tags",)) == ("b,a",)


def test_archive_group_key_ignores_internal_tags():
    """Internal tags say nothing about an archive's contents, they must not create their own group."""
    tagged = _grouped_archive(tags=("@PROT", "important"))
    untagged = _grouped_archive(tags=("important",))
    assert archive_group_key(tagged, ("tags",)) == archive_group_key(untagged, ("tags",)) == ("important",)


def test_archive_group_key_missing_metadata():
    """Archives without host / user metadata (e.g. transferred from borg 1.x) form their own group."""
    assert archive_group_key(_grouped_archive(host=None, user=None), ("host", "user")) == ("", "")
    assert archive_group_key(_grouped_archive(host="", user=""), ("host", "user")) == ("", "")


def test_group_archives():
    home1, home2 = _grouped_archive(host="host1"), _grouped_archive(host="host2")
    etc1 = _grouped_archive(name="etc", host="host1")
    archives = [home1, etc1, home2]

    assert group_archives(archives, ()) == {(): archives}  # no grouping: one group with all archives
    assert group_archives(archives, ("name",)) == {("home",): [home1, home2], ("etc",): [etc1]}
    assert group_archives(archives, ("name", "host")) == {
        ("home", "host1"): [home1],
        ("etc", "host1"): [etc1],
        ("home", "host2"): [home2],
    }


def test_format_group_key():
    assert format_group_key(("home", "host1"), ("name", "host")) == "name='home', host='host1'"


def test_group_by_spec():
    assert GroupBySpec("name") == "name"
    assert GroupBySpec("name,host,user,tags") == "name,host,user,tags"
    assert GroupBySpec("") == GroupBySpec("none") == ""
    # the parsed value is fed through the spec again by the argument parser, so it must be stable:
    assert GroupBySpec(GroupBySpec("name")) == GroupBySpec("name")
    assert GroupBySpec(GroupBySpec("none")) == GroupBySpec("none")
    with pytest.raises(ArgumentTypeError, match="Invalid group-by key: bogus"):
        GroupBySpec("name,bogus")
    with pytest.raises(ArgumentTypeError, match="Duplicate group-by key: name"):
        GroupBySpec("name,name")


def _create_series(archiver, backup_files, name, hour):
    """Create a two archive series called *name*, one archive on 2024-01-01, one on 2024-01-02."""
    _create_archive_ts(archiver, backup_files, name, 2024, 1, 1, H=hour)
    _create_archive_ts(archiver, backup_files, name, 2024, 1, 2, H=hour)


def _create_shared_repo_series(archiver, backup_files, monkeypatch):
    """host1 has a "home" and an "etc" series, host2 has an own, unrelated "home" series."""
    monkeypatch.setenv("BORG_HOSTNAME", "host1")
    _create_series(archiver, backup_files, "home", hour=10)
    _create_series(archiver, backup_files, "etc", hour=11)
    monkeypatch.setenv("BORG_HOSTNAME", "host2")
    _create_series(archiver, backup_files, "home", hour=12)
    monkeypatch.delenv("BORG_HOSTNAME")


def test_prune_groups_by_name_and_host_by_default(archivers, request, backup_files, monkeypatch):
    """Unrelated backup sets must not compete for the same retention slots."""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    _create_shared_repo_series(archiver, backup_files, monkeypatch)
    # 3 groups: (home, host1), (etc, host1), (home, host2) - each keeps its own daily archive.
    output = cmd(archiver, "prune", "--list", "--dry-run", "--keep-daily=1")
    assert output.count("Would prune:") == 3


def test_prune_group_by_name_only(archivers, request, backup_files, monkeypatch):
    """--group-by name pools the same series name of different hosts into one retention pool."""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    _create_shared_repo_series(archiver, backup_files, monkeypatch)
    # 2 groups: "home" (of both hosts) and "etc".
    output = cmd(archiver, "prune", "--list", "--dry-run", "--group-by", "name", "--keep-daily=1")
    assert output.count("Would prune:") == 4


def test_prune_group_by_none(archivers, request, backup_files):
    """--group-by "" applies the rules to all selected archives at once."""
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    _create_series(archiver, backup_files, "home", hour=10)
    _create_series(archiver, backup_files, "etc", hour=11)
    cmd(archiver, "prune", "--group-by", "", "--keep-daily=1")
    output = cmd(archiver, "repo-list", "--format={name}{NL}")
    assert output.split() == ["etc"]  # only the single newest archive of all 4 survives


def test_prune_group_by_tags(archivers, request, backup_files):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    _create_series(archiver, backup_files, "home", hour=10)
    _create_series(archiver, backup_files, "etc", hour=11)
    cmd(archiver, "tag", "--add=alice", "-a", "name:home")
    cmd(archiver, "tag", "--add=bob", "-a", "name:etc")
    output = cmd(archiver, "prune", "--list", "--dry-run", "--group-by", "tags", "--keep-daily=1")
    assert output.count("Would prune:") == 2  # one archive pruned per tag group


def test_prune_group_by_logs_a_summary_per_group(archivers, request, backup_files, monkeypatch):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    _create_shared_repo_series(archiver, backup_files, monkeypatch)
    output = cmd(archiver, "prune", "--info", "--dry-run", "--keep-daily=1")
    assert "Group (name='home', host='host1'): 2 archives, keeping 1, pruning 1." in output
    assert "Group (name='etc', host='host1'): 2 archives, keeping 1, pruning 1." in output
    assert "Group (name='home', host='host2'): 2 archives, keeping 1, pruning 1." in output


def test_prune_group_by_json(archivers, request, backup_files, monkeypatch):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    for host, hour in (("host1", 10), ("host2", 11)):
        monkeypatch.setenv("BORG_HOSTNAME", host)
        _create_series(archiver, backup_files, "home", hour=hour)
    monkeypatch.delenv("BORG_HOSTNAME")
    prune_result = json.loads(
        cmd(archiver, "prune", "--json", "--dry-run", "--group-by", "name,host", "--keep-daily=1")
    )
    groups = {tuple(sorted(archive["group"].items())) for archive in prune_result["archives"]}
    assert groups == {(("host", "host1"), ("name", "home")), (("host", "host2"), ("name", "home"))}


def test_prune_group_by_invalid_key(archivers, request):
    archiver = request.getfixturevalue(archivers)
    cmd(archiver, "repo-create", RK_ENCRYPTION)
    output = cmd(archiver, "prune", "--group-by", "bogus", "--keep-daily=1", exit_code=2)
    assert "Invalid group-by key: bogus" in output
