import pytest
import time

import features
from controllers.base import COMMANDS
from controllers.types import Questions, Todos, Works
from features.kanban.board import sources_of
from features.kanban.lanes import lane_of
from features.plans.controller import Plans
from resources.base import AGENT, SYSTEM, USER
from tests.conftest import fresh, refused


def board(record, **lens) -> dict:
    return COMMANDS["todo"]["board"](Todos(record, actor=USER), **lens)


def shift(record, n: int, lane: str, **words):
    return COMMANDS["todo"]["shift"](Todos(record, actor=USER), n, lane, **words)


def lane(record, n: int) -> str:
    journal = features.FEATURES["kanban"].journal.at(record)
    return lane_of(sources_of(journal), Todos(record, actor=USER).load(n))


def test_every_to_do_sits_in_one_lane_by_its_state_and_the_first_rule_that_matches():
    features.load()
    record = fresh()
    todos = Todos(record, actor=USER)
    plain, blocked, waiting, started, asked, done, old = (todos.create(f"row {i}").n for i in range(7))
    todos.block(blocked, "the api is down")
    todos.after(waiting, plain)
    Works(record, actor=AGENT).create("building it", todo=started)
    Works(record, actor=AGENT, force="two at once for the test").create("another", todo=asked)
    Questions(record, actor=AGENT).create("which way", about=f"todo:{asked}", options=[{"title": "A"}, {"title": "B"}], pick=1)
    todos.complete(done, "shipped")
    todos.complete(old, "long ago")
    closed = todos.load(old)
    closed.completed = time.time() - 30 * 86400
    todos.save(closed, "updated")
    assert [lane(record, n) for n in (plain, blocked, waiting, started, asked, done)] == \
        ["todo", "held", "held", "doing", "asked", "done"], "a question outranks open work, work outranks a hold"
    on_board = {card["n"] for column in board(record)["lanes"] for card in column["cards"]}
    assert old not in on_board and done in on_board, "a row closed longer ago than done_days is off the board"
    cards = {card["n"]: card for column in board(record)["lanes"] for card in column["cards"]}
    assert (cards[plain]["targets"], cards[blocked]["targets"], cards[waiting]["targets"], cards[started]["targets"], cards[done]["targets"]) == \
        (["held", "doing", "done"], ["todo", "done"], [], [], ["todo"]), "each card carries the lanes it may move to"
    assert "waits on todo" in cards[waiting]["reason"] and cards[blocked]["reason"] == "blocked: the api is down", "a held card says why"
    helped, finished = todos.create("row with a helper").n, todos.create("row a helper finished").n
    todos.assign(helped, "helper:3")
    todos.assign(finished, "helper:4")
    todos.update(finished, pending={"how": "built", "worktree": "w"})
    from features.kanban.lanes import reason_of
    sources = sources_of(features.FEATURES["kanban"].journal.at(record))
    reasons = {n: reason_of(sources, todos.load(n)) for n in (helped, finished)}
    assert (reasons[helped], reasons[finished]) == ("helper 3 has it", "done by helper 4, waits for its merge"), "a card a helper holds says which helper, and one it finished says it waits for its merge"


def test_a_row_outside_an_active_plan_stays_in_to_do_and_the_hold_is_one_line():
    features.load()
    record = fresh()
    todos = Todos(record, actor=USER)
    inside, outside, later = todos.create("in the plan").n, todos.create("not in it").n, todos.create("in a later phase").n
    plans = Plans(record, actor=AGENT)
    plan = plans.create("Ship it", goal="shipped")
    plans.phase(plan.n, "Build", when="built")
    plans.phase(plan.n, "Ship", when="shipped")
    plans.stage(plan.n, "todos")
    plans.place(plan.n, 1, [inside])
    plans.place(plan.n, 2, [later])
    plans.ready(plan.n)
    plans = Plans(record, actor=USER)
    plans.approve(plan.n)
    plans.start(plan.n)
    assert (lane(record, inside), lane(record, outside)) == ("todo", "todo"), "the plan's global hold moves no card"
    assert board(record)["plan_hold"].startswith(f"Plan {plan.n} is active"), "it is said once above the lanes"
    reasons = {card["n"]: card["reason"] for column in board(record)["lanes"] for card in column["cards"]}
    assert reasons[later] == f"plan {plan.n} holds it until phase 2", "a row of a later phase says which plan holds it and until when"
    assert f"plan {plan.n} holds todo {later} until its phase 2" in refused(lambda: shift(record, later, "todo")), "a card a plan holds cannot be dragged out of its lane"
    from features.kanban.shifts import Shift
    with pytest.raises(NotImplementedError):
        Shift(("todo",), "held").run(None, None, "", "")
    card_numbers = lambda **lens: sorted(card["n"] for column in board(record, **lens)["lanes"] for card in column["cards"])
    assert (card_numbers(plan=plan.n), card_numbers(plan=plan.n + 99), card_numbers(agent="nobody")) == ([inside, later], [], []), \
        "a board seen through a plan holds only that plan's cards, through a plan that does not exist none, and through an agent with no work none"


def test_a_card_moves_through_the_journals_own_actions_and_refuses_in_words_that_name_a_command():
    features.load()
    record = fresh()
    todos = Todos(record, actor=USER)
    n, other = todos.create("a card").n, todos.create("another").n
    assert refused(lambda: shift(record, n, "held")).endswith('needs --why "<why>"'), "blocking asks why"
    shift(record, n, "held", why="waiting on the api")
    assert todos.load(n).blocked == "waiting on the api", "to held blocks the row"
    shift(record, n, "todo")
    assert todos.load(n).blocked == "", "back to to do unblocks it"
    before = todos.load(n).updated
    shift(record, n, "todo")
    assert todos.load(n).updated == before, "a shift into the lane it is in writes nothing"
    todos.after(n, other)
    assert refused(lambda: shift(record, n, "done")).endswith("close that first"), "a row that waits cannot be closed"
    todos.after(n, other, off=True)
    shift(record, n, "done", how="built it")
    assert todos.load(n).outcome == "built it", "to done closes the row"
    assert "--why" in refused(lambda: shift(record, n, "todo")), "reopening asks why"
    todos.block(n, "an old block")
    shift(record, n, "todo", why="it came back")
    assert (todos.load(n).completed, lane(record, n)) == (0.0, "todo"), "back to to do reopens it and lifts an old block"
    assert "journal question ask" in refused(lambda: shift(record, n, "asked")), "only a question puts a card in needs you"
    assert refused(lambda: shift(record, n, "sideways")).startswith("a lane is one of"), "an unknown lane is refused"
    shift(record, n, "doing")
    assert "journal work end" in refused(lambda: shift(record, n, "todo")), "a card with open work is left to its agent"


def test_the_board_and_its_moves_are_refused_while_the_feature_is_off():
    features.load()
    record = fresh()
    n = Todos(record, actor=USER).create("a card").n
    record.features = {"kanban": False}
    assert (refused(lambda: board(record)), refused(lambda: shift(record, n, "held", why="x"))) == \
        ("the kanban feature is off: switch it on in Settings, or with journal feature switch kanban",) * 2, "both commands say so, and how to switch it on"


def test_a_cards_words_pass_the_formatters_like_every_other_field():
    features.load()
    record = fresh()
    Todos(record, actor=USER).create("[!info] tagged title")
    assert [c["title"] for lane in board(record)["lanes"] for c in lane["cards"]] == ["tagged title"], "a leftover tag is taken off the card's title"
    from controllers.types import Docs
    from features.format import FORMATTERS, formatted, markdown
    doc = Docs(record, actor=USER).create("[!info] A guide", abstract="short", brief="Words")
    Docs(record, actor=USER).section(doc.n, "Steps", "Do it")
    assert markdown(Docs(record, actor=USER).load(doc.n), record) == "# A guide\n\n_short_\n\nWords\n\n## Steps\n\nDo it\n", \
        "a downloaded document is its title, its line, its words and its sections, with the same tags taken off"
    broken = (lambda text, _: 1 / 0, ())
    FORMATTERS.add(None, broken)
    try:
        assert formatted("kept", record) == "kept", "a formatter that fails leaves the words as they were"
        with pytest.raises(ZeroDivisionError):
            formatted("kept")
    finally:
        FORMATTERS.remove(broken)


def test_a_parked_to_do_is_held_not_doing():
    record = fresh()
    row = Todos(record, actor=AGENT).create("set aside")
    Todos(record, actor=AGENT).start(row.n)
    Works(record, actor=AGENT).action("park")("waiting on the build")
    cards = {card["n"]: card for column in board(record)["lanes"] for card in column["cards"]}
    assert (lane(record, row.n), cards[row.n]["reason"]) == ("held", "parked: waiting on the build"), "parked work leaves Doing and says why"
    handed = Todos(record, actor=AGENT).create("with a helper")
    Todos(record, actor=SYSTEM).update(handed.n, assigned="helper:7")
    cards = {card["n"]: card for column in board(record)["lanes"] for card in column["cards"]}
    assert (lane(record, handed.n), cards[handed.n]["targets"]) == ("doing", []), "a helper's card is Doing on a board with no work of its own, and draws"


def test_an_ended_plan_holds_none_of_its_rows():
    from features.plans.progress import held
    record = fresh()
    plans, row = Plans(record, actor=AGENT), Todos(record, actor=AGENT).create("moved between plans")
    for title in ("the old plan", "the new plan"):
        plan = plans.create(title, goal="a goal")
        plans.phase(plan.n, "one", when="done")
        plans.place(plan.n, 1, [row.n])
        plans.ready(plan.n)
    plans.abandon(plan.n - 1, why="rewritten")
    plans = Plans(record, actor=USER)
    plans.approve(plan.n)
    plans.start(plan.n)
    assert (held(record, Todos(record, actor=USER).load(row.n)), lane(record, row.n)) == (False, "todo"), \
        "only the running plan's phase counts; the abandoned one still linking the row holds nothing"


def test_a_card_dropped_before_another_takes_that_place_and_its_priority():
    features.load()
    record = fresh()
    todos = Todos(record, actor=USER)
    first, second, third = (todos.create(title).n for title in ("First", "Second", "Third"))
    todos.priority(first, "high")
    todos.place(third, before=second)
    column = [card["n"] for lane in board(record)["lanes"] if lane["key"] == "todo" for card in lane["cards"]]
    assert column == [first, third, second], f"the dropped card sits right before the one it was dropped on: {column}"
    todos.place(third, before=first)
    assert todos.load(third).priority == todos.load(first).priority, "dropped above a higher one, it takes that priority, so auto mode works it in that order"
    assert [t.n for t in todos.rows.standing()][:2] == [third, first], "and the order the board shows is the order the journal hands out"
    todos.complete(second, how="done")
    assert "same column" in refused(lambda: todos.place(first, before=second)), "a card is only placed among the open cards of its column"
    more = [todos.create(f"More {i}").n for i in range(120)]
    page = next(one for one in board(record)["lanes"] if one["key"] == "todo")
    assert (len(page["cards"]), page["total"], bool(page["next"])) == (50, 122, True), "a lane answers its first fifty cards and how many it holds"
    todos.complete(more[60], how="done")
    todos.place(more[119], before=more[100])
    second_page = board(record, lane="todo", after=page["next"])["lanes"]
    assert [one["key"] for one in second_page] == ["todo"] and len(second_page[0]["cards"]) == 50 and second_page[0]["total"] == 121 and not {c["n"] for c in page["cards"]} & {c["n"] for c in second_page[0]["cards"]}, \
        "the next page of one lane follows the cursor and shares no card with the first, though a card was completed and another dropped in between"
    last = board(record, lane="todo", after=second_page[0]["next"])["lanes"][0]
    assert (len(last["cards"]), last["next"]) == (21, ""), "the last page has what is left and no next page"
    found = board(record, query="More 11")["lanes"]
    assert {c["title"] for one in found for c in one["cards"]} == {"More 11", *(f"More 11{i}" for i in range(10))}, "the filter reaches cards beyond the first page"
    only = board(record, only=more[0])["lanes"]
    assert [(one["key"], [c["n"] for c in one["cards"]]) for one in only if one["cards"]] == [("todo", [more[0]])], "one card can be asked for by number whatever page it is on"
    from controllers import stored
    from tests.kit import counted
    stored.forget_held()
    board(record)
    stored.forget_held()
    with counted() as work:
        board_view = board(record)
    assert ([kind for kind, _ in work.parsed if kind == "todo"], sum(len(one["cards"]) for one in board_view["lanes"]) > 10) == ([], True), \
        "the board of a server that holds nothing yet is built from the summaries of the to-dos, and parses none of them"


def test_a_subagents_task_list_stays_off_the_main_list_and_each_agent_holds_its_own_work():
    from surfaces.listing import Listing, listing
    features.load()
    record = fresh()
    main = Todos(record, actor=AGENT)
    main.start(main.create("The main agent's own work").n)
    task = Todos(record, actor=USER).task("x1", "Draw the plan card", brief="the chat card from the design")
    shared = Todos(record, actor=USER).create("An ordinary to-do handed over")
    Todos(record, actor=USER).assign(shared.n, to="x1")
    cards = [card["n"] for lane in board(record)["lanes"] for card in lane["cards"]]
    listed = [row["n"] for row in listing(Todos(record, actor=USER), record, Listing.from_query({}))["rows"]]
    assert task.n not in cards and task.n not in listed and shared.n in listed, "a subagent's own task stays off the board and the list; a handed-over to-do stays on them"
    helper = Todos(record, actor=AGENT, agent="x1")
    helper.start(task.n)
    assert [t["state"] for t in main.tasks("x1")] == ["doing", "waiting"], "the subagent takes its task while the main agent has work in hand"
    helper.complete(task.n, how="drawn")
    assert [t["state"] for t in main.tasks("x1")] == ["done", "waiting"], "and its inspector sees how far along it is"
    from tests.kit import report
    report(record, "working", "PreToolUse")
    assert [(chip["title"], chip["status"], chip["todo"] > 0) for chip in board(record)["agents"]] == [("Main agent", "working", True)], \
        "the board names the main agent that is working and what it holds"



def test_a_listing_says_whether_older_rows_are_left_out_from_a_count_kept_as_rows_change():
    from surfaces.listing import Listing, listing
    features.load()
    record = fresh()
    todos = Todos(record, actor=SYSTEM)
    made = [todos.create(f"to-do {i}").n for i in range(3)]
    more = lambda **query: listing(todos, record, Listing.from_query({k: str(v) for k, v in query.items()}))["more"]
    assert (more(last=2), more(last=3)) == (True, False), "a page shorter than the open rows says there are more"
    todos.complete(made[0], how="done")
    assert (more(last=2), more(last=2, completed=1), more(last=3, completed=1)) == (False, True, False), "a closed row counts only when closed rows are asked for"
