"""How long does a Hesper turn take, and what would an extra round cost?

Round 37 proposes that the five citizen phases (trade, movement, gather,
production, building) cycle inside one turn instead of running once, so that a
citizen can walk, unload and harvest in the same day. One of the objections the
author cannot weigh from outside is the wall clock: at a thousand citizens, does
running the cycle two or three times make a turn too slow to resolve inside its
twenty-minute on-time window?

This measures it on a real world rather than guessing. It builds a scratch world
(`~/data/hesper-bench`, genesis seed 4242, the full 512 x 512 map), lands N
citizens on wood deposits with tools and food, queues a batch for each, and
resolves a turn with every phase timed individually.

The number that answers the objection is the **empty cost** of a citizen phase:
what a phase costs when it has no rows to resolve. A round is one more pass of
the cycle, and in a round where a citizen has nothing queued of that phase the
pass still happens. So

    marginal cost of one extra round  ~=  sum of the citizen phases' empty costs

and the per-action work is paid exactly once however many rounds there are,
because each action belongs to exactly one round.

    python research/hesper/phase-rounds/bench.py --citizens 1000

Run it twice: the first turn of a fresh world pays for the first `state_hash`
over 262,144 tiles either way, and that cost is per turn, not per round.
"""

import argparse
import json
import os
import sqlite3
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[3]))

from hesper import db, turn as turn_mod                     # noqa: E402
from hesper.core import loader, phases as phases_mod        # noqa: E402

DB = Path.home() / "data" / "hesper-bench" / "h.db"
CITIZEN_PHASES = ("trade", "movement", "gather", "production", "building")


def land_citizens(conn, n, turn, tools=True):
    """Land `n` citizens on wood deposits, each with a tool, food and a tent.

    Straight into the tables: registration goes through proof of work and a
    rate limiter, and neither is what this measures.
    """
    settler = conn.execute("SELECT sheet_id FROM kinds WHERE slug = 'settler'").fetchone()[0]
    tool = conn.execute("SELECT sheet_id FROM kinds WHERE slug = 'hand_tools'").fetchone()[0]
    tent = conn.execute("SELECT sheet_id FROM kinds WHERE slug = 'tent'").fetchone()[0]
    tiles = conn.execute(
        "SELECT x, y FROM tiles WHERE resource = 'wood' AND resource_qty > 20 "
        "ORDER BY y, x LIMIT ?", (n,)).fetchall()
    if len(tiles) < n:
        raise SystemExit(f"only {len(tiles)} wood tiles with stock; asked for {n}")
    made = []
    for i, (x, y) in enumerate(tiles):
        pid, avatar = "p_b%05d" % i, "th_b%05d" % i
        # The player first without its avatar, then the avatar, then the link:
        # `players.avatar_thing_id` is a foreign key into a row that does not
        # exist yet, and `things.owner_player` is one into the player.
        conn.execute(
            "INSERT INTO players (player_id, name, kind, created_at, landed_turn, "
            "home_x, home_y, coins, ap_budget, status) "
            "VALUES (?, ?, 'agent', '2026-09-09', ?, ?, ?, 10, 10, 'active')",
            (pid, "bench%05d" % i, turn - 5, x, y))
        conn.execute(
            "INSERT INTO things (thing_id, sheet_id, owner_player, x, y, fixed, "
            "props_json, state_json, durability, move_points, created_turn) "
            "VALUES (?, ?, ?, ?, ?, 0, '{}', '{}', 10, 0, ?)",
            (avatar, settler, pid, x, y, turn - 5))
        conn.execute("UPDATE players SET avatar_thing_id = ? WHERE player_id = ?",
                     (avatar, pid))
        conn.execute(
            "INSERT INTO inventories (holder_kind, holder_id, item, qty) "
            "VALUES ('thing', ?, 'food', 4)", (avatar,))
        if tools:
            conn.execute(
                "INSERT INTO things (thing_id, sheet_id, owner_player, x, y, fixed, "
                "props_json, state_json, durability, move_points, created_turn) "
                "VALUES (?, ?, ?, ?, ?, 0, '{}', ?, 40, 0, ?)",
                ("th_t%05d" % i, tool, pid, x, y,
                 json.dumps({"carried_by": avatar}), turn - 5))
        # A pitched tent on the tile: upkeep looks for shelter and a store, and
        # a world where nobody owns anything is not the world being sized.
        conn.execute(
            "INSERT INTO things (thing_id, sheet_id, owner_player, x, y, fixed, "
            "props_json, state_json, durability, move_points, created_turn) "
            "VALUES (?, ?, ?, ?, ?, 0, '{}', ?, 30, 0, ?)",
            ("th_n%05d" % i, tent, pid, x, y, json.dumps({"pitched": True}), turn - 5))
        made.append((pid, avatar, x, y))
    return made


def queue(conn, citizens, turn, batch):
    """One request a citizen, `batch` actions in it, all queued for `turn`."""
    at = int(time.time() * 1000)
    for i, (pid, avatar, x, y) in enumerate(citizens):
        rid = "r%03x%08x" % (turn, i)          # unique per turn, so a bench world resolves many
        conn.execute(
            "INSERT INTO requests (request_id, target_turn, at_ms, route, player_id, "
            "client_action_id, body_sha256, body_json, queued) "
            "VALUES (?, ?, ?, 'POST /api/actions', ?, ?, '0', '{}', 1)",
            (rid, turn, at + i, pid, "bench-%d-%d" % (turn, i)))
        for idx, (kind, payload, cost) in enumerate(batch(pid, avatar, x, y)):
            conn.execute(
                "INSERT INTO actions (action_id, request_id, idx, type, payload_json, "
                "ap_cost, target_turn, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued')",
                ("a%03x%06x%02d" % (turn, i, idx), rid, idx, kind,
                 json.dumps(payload, sort_keys=True), cost, turn))


def walk_and_gather(pid, avatar, x, y):
    """The batch a real citizen sends: a step, then two pulls. 3 AP of 10."""
    return [("move", {"path": [[x + 1, y]]}, 1),
            ("gather", {"pulls": 2}, 2)]


def _no_seal(*args, **kwargs):
    """A seal receipt the recorder accepts, so the bench prints no seal noise."""
    return {"ok": False, "reason": "bench world: not sealed", "note": None}


def timed_phases(results):
    """Wrap `Scheduler.run_phase` so every phase reports its own seconds."""
    original = phases_mod.Scheduler.run_phase

    def wrapper(self, phase, *args, **kwargs):
        started = time.perf_counter()
        try:
            return original(self, phase, *args, **kwargs)
        finally:
            results.append((phase.order, phase.name, phase.module,
                            time.perf_counter() - started))

    phases_mod.Scheduler.run_phase = wrapper
    return original


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--db", default=str(DB))
    ap.add_argument("--citizens", type=int, default=1000)
    ap.add_argument("--empty", action="store_true",
                    help="queue nothing: every citizen phase pays only its empty cost")
    ap.add_argument("--reset", action="store_true",
                    help="delete the bench world's citizens and turns first")
    args = ap.parse_args()

    conn = db.connect(args.db)
    db.apply_migrations(conn)
    conn.row_factory = sqlite3.Row
    load_result = loader.load(laws_dir=None, law_version=1,
                              turn=int(db.meta_get(conn, "current_turn", "0")) + 1,
                              conn=conn)
    db.apply_module_migrations(conn, load_result)

    if args.reset:
        for table in ("actions", "requests", "inventories", "things", "players",
                      "events", "turns", "tile_items", "rate_counters"):
            conn.execute(f"DELETE FROM {table}")
        db.meta_set(conn, "current_turn", "0")
        conn.commit()

    turn = int(db.meta_get(conn, "current_turn", "0")) + 1
    have = int(conn.execute("SELECT COUNT(*) AS n FROM players").fetchone()["n"])
    if have < args.citizens:
        citizens = land_citizens(conn, args.citizens, turn)
        conn.commit()
    else:
        citizens = [(r["player_id"], r["avatar_thing_id"], r["home_x"], r["home_y"])
                    for r in conn.execute(
                        "SELECT player_id, avatar_thing_id, home_x, home_y FROM players "
                        "ORDER BY player_id LIMIT ?", (args.citizens,))]
    if not args.empty:
        queue(conn, citizens, turn, walk_and_gather)
    conn.commit()

    timings = []
    timed_phases(timings)
    started = time.perf_counter()
    # No state_dir: the event file, the reports and the render are per turn and
    # not per round, and they are not what an extra round would cost.
    resolution = turn_mod.resolve(conn, None, load_result,
                                  sealer=_no_seal, git_sealer=_no_seal)
    total = time.perf_counter() - started

    print(f"world      {args.db}")
    print(f"citizens   {len(citizens)}")
    print(f"queued     {'nothing (empty pass)' if args.empty else 'move + gather 2 each'}")
    print(f"turn       {resolution.turn}   resolved {resolution.actions_resolved} "
          f"failed {resolution.actions_failed}   events {resolution.event_count}")
    print(f"total      {total:.3f} s   (engine says {resolution.duration_ms} ms)")
    print()
    print("  order  phase        module        seconds")
    cycle = 0.0
    for order, name, module, seconds in sorted(timings):
        mark = " *" if name in CITIZEN_PHASES else "  "
        if name in CITIZEN_PHASES:
            cycle += seconds
        print(f"{mark} {order:>5}  {name:<12} {module:<13} {seconds:7.3f}")
    print()
    print(f"  * the five citizen phases together: {cycle:.3f} s")
    print("    one extra round costs about that much, plus nothing per action:")
    print("    an action belongs to exactly one round and is resolved once.")
    conn.close()


if __name__ == "__main__":
    main()
