#!/usr/bin/env python3
#
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# 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 General Public License for more details.
#
# You should have received a copy of the GNU 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.
"""Self-checks for the bits of mailctl with real logic in them:

  - the senders address merge: notmuch dedupes on name-addr, so one address
    appears once per display name it ever used, and we merge on the address.
  - subject_terms: tokenizing/stopword-filtering subject lines, counting each
    term once per subject.
  - validate_accounts: the config gate. Every rejection case here is a typo
    that would otherwise silently produce a query matching nothing, or a
    draft written under the wrong identity.

Run: ./test_mailctl.py
"""

import io
import json
import os
import tempfile
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch

# mailctl validates its account config at import time, so a valid one has to
# exist before the import below. Point it at a throwaway maildir tree.
_tmp = tempfile.TemporaryDirectory()
FIXTURE_ROOT = Path(_tmp.name)
(FIXTURE_ROOT / "mail" / "acct-a" / "Drafts").mkdir(parents=True)
(FIXTURE_ROOT / "mail" / "acct-b").mkdir(parents=True)
FIXTURE_CONFIG = FIXTURE_ROOT / "accounts.json"
FIXTURE_CONFIG.write_text(json.dumps({"accounts": {
    "acct-a": {"maildir": "acct-a", "address": "a@example.org",
               "drafts": "Drafts"},
    "acct-b": {"maildir": "acct-b", "address": "b@example.org",
               "drafts": None},
}}))
os.environ["MAILCTL_CONFIG"] = str(FIXTURE_CONFIG)
os.environ["MAILCTL_MAIL_ROOT"] = str(FIXTURE_ROOT / "mail")

import mailctl  # noqa: E402  (must follow the env vars above)

MAIL = FIXTURE_ROOT / "mail"


def senders_json(raw, **overrides):
    """Run cmd_senders against a canned notmuch reply, return parsed JSON."""
    opts = {"query": "*", "account": None, "top": None, "json": True}
    opts.update(overrides)
    args = type("Args", (), opts)()
    buf = io.StringIO()
    with patch.object(mailctl, "run_notmuch", return_value=json.dumps(raw)), \
            redirect_stdout(buf):
        mailctl.cmd_senders(args)
    return json.loads(buf.getvalue())


def test_merges_on_address_keeping_longest_name():
    got = senders_json([
        {"name": "", "address": "a@x", "count": 112},
        {"name": "DPReview", "address": "a@x", "count": 353},
        {"name": "B", "address": "b@x", "count": 5},
    ])
    assert got == [
        {"address": "a@x", "name": "DPReview", "count": 465},
        {"address": "b@x", "name": "B", "count": 5},
    ], got


def test_top_truncates_after_sorting():
    got = senders_json([
        {"name": "small", "address": "s@x", "count": 1},
        {"name": "big", "address": "b@x", "count": 99},
    ], top=1)
    assert [a["address"] for a in got] == ["b@x"], got


def test_empty():
    assert senders_json([]) == []


def test_subject_terms_counts_each_term_once_per_subject():
    # "promo" three times in one subject must not outrank "sconto" in two
    got = mailctl.subject_terms(["Promo promo PROMO", "sconto", "Sconto!"])
    assert got["promo"] == 1, got
    assert got["sconto"] == 2, got


def test_subject_terms_drops_stopwords_digits_and_emoji():
    got = mailctl.subject_terms(["🔥 Le offerte di oggi for you 2024 ⏳"])
    assert set(got) == {"offerte"}, got


def test_subject_terms_keeps_accented_words():
    got = mailctl.subject_terms(["Località e novità"])
    assert set(got) == {"località", "novità"}, got


def test_subject_terms_empty():
    assert mailctl.subject_terms([]) == {}
    assert mailctl.subject_terms(["", "🔥"]) == {}


def rejects(accounts_value, expect_in_message):
    """Assert a config is refused, and that the message names the problem.

    Checking the message matters as much as the exit: these fire on the user's
    own typo, and a rejection that doesn't say which account and which field
    is barely better than a silent wrong answer.
    """
    buf = io.StringIO()
    try:
        with patch("sys.stderr", buf):
            mailctl.validate_accounts(accounts_value, MAIL)
    except SystemExit as e:
        assert e.code == 2, f"expected exit 2, got {e.code}"
        msg = buf.getvalue()
        assert expect_in_message in msg, f"want {expect_in_message!r} in:\n{msg}"
        return
    raise AssertionError(f"config was accepted but should not be: {accounts_value}")


def good(**overrides):
    spec = {"maildir": "acct-a", "address": "a@example.org", "drafts": "Drafts"}
    spec.update(overrides)
    return {"accounts": {"acct-a": spec}}


def test_valid_config_returns_both_maps():
    accounts, drafts = mailctl.validate_accounts(json.loads(
        FIXTURE_CONFIG.read_text()), MAIL)
    assert accounts == {"acct-a": ("acct-a", "a@example.org"),
                        "acct-b": ("acct-b", "b@example.org")}, accounts
    assert drafts == {"acct-a": "Drafts", "acct-b": None}, drafts


def test_rejects_misspelled_maildir():
    # the typo this whole gate exists for: scoping would match zero mail
    rejects(good(maildir="acct-A"), "does not exist")


def test_rejects_misspelled_drafts_dir():
    # would write a draft into a folder mbsync never syncs back
    rejects(good(drafts="Bozze"), "does not exist")


def test_rejects_absolute_and_traversing_maildir():
    rejects(good(maildir="/etc"), "plain subdirectory")
    rejects(good(maildir="../../etc"), "plain subdirectory")


def test_rejects_bad_address():
    rejects(good(address="a@example"), "not a valid email address")
    rejects(good(address="not-an-address"), "not a valid email address")


def test_rejects_unknown_field():
    # catches "addresss"/"maildirs" style typos instead of ignoring them
    rejects(good(adress="a@example.org"), "unknown field")


def test_rejects_missing_required_field():
    spec = good()
    del spec["accounts"]["acct-a"]["address"]
    rejects(spec, 'missing required field "address"')


def test_rejects_duplicate_maildir():
    rejects({"accounts": {
        "one": {"maildir": "acct-a", "address": "a@example.org", "drafts": None},
        "two": {"maildir": "acct-a", "address": "b@example.org", "drafts": None},
    }}, "is used by 2 accounts")


def test_rejects_structural_problems():
    rejects([], "must be a JSON object")
    rejects({}, 'missing top-level "accounts" key')
    rejects({"accounts": {}}, "non-empty")
    rejects({"accounts": {"acct-a": "acct-a"}}, "must be an object")
    rejects({"accounts": {"Acct A": good()["accounts"]["acct-a"]}},
            "invalid name")


def test_message_names_the_offending_account():
    buf = io.StringIO()
    try:
        with patch("sys.stderr", buf):
            mailctl.validate_accounts({"accounts": {
                "fine": {"maildir": "acct-b", "address": "b@example.org"},
                "broken": {"maildir": "nope", "address": "c@example.org"},
            }}, MAIL)
    except SystemExit:
        pass
    assert '"broken"' in buf.getvalue(), buf.getvalue()


def test_drafts_null_is_allowed():
    accounts, drafts = mailctl.validate_accounts(good(drafts=None), MAIL)
    assert drafts == {"acct-a": None}, drafts


def test_drafts_field_is_optional():
    spec = good()
    del spec["accounts"]["acct-a"]["drafts"]
    _, drafts = mailctl.validate_accounts(spec, MAIL)
    assert drafts == {"acct-a": None}, drafts


def test_load_accounts_rejects_bad_json():
    bad = FIXTURE_ROOT / "bad.json"
    bad.write_text("{not json")
    buf = io.StringIO()
    try:
        with patch("sys.stderr", buf):
            mailctl.load_accounts(bad, MAIL)
    except SystemExit as e:
        assert e.code == 2
        assert "not valid JSON" in buf.getvalue(), buf.getvalue()
        return
    raise AssertionError("bad JSON was accepted")


def test_load_accounts_missing_file_explains_how_to_create_it():
    buf = io.StringIO()
    try:
        with patch("sys.stderr", buf):
            mailctl.load_accounts(FIXTURE_ROOT / "absent.json", MAIL)
    except SystemExit as e:
        assert e.code == 2
        assert "no config at" in buf.getvalue(), buf.getvalue()
        return
    raise AssertionError("missing config was accepted")


if __name__ == "__main__":
    for name, fn in sorted(globals().items()):
        if name.startswith("test_") and callable(fn):
            fn()
    print("ok")
