import asyncio
import json
import subprocess
from pathlib import Path

import httpx
import pytest

from src import main
from src.today_store import TodayStore


ROOT = Path(__file__).parents[1]
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"


async def recap_time_grant(client, headers, payload):
    challenge = await client.post(
        "/api/v1/today/recaps/log-time", json=payload, headers=headers
    )
    assert challenge.status_code == 428
    authorization = await client.post(
        "/api/v1/fresh-authorization",
        json={
            "access_token": "correct horse battery staple",
            "action": "log_recap_time",
            "target": challenge.json()["detail"]["target"],
        },
        headers=headers,
    )
    assert authorization.status_code == 201
    return authorization.json()["grant"]


def test_mobile_recap_time_logging_controls_are_explicit_accessible_and_opt_in():
    html = (ROOT / "frontend" / "index.html").read_text()
    source = TODAY_RECAP.read_text()
    css = (ROOT / "frontend" / "dashboard.css").read_text()

    assert 'id="save-log-today-recap"' in html
    assert "Log selected time to Gitea" in html
    assert 'type="checkbox"' in source
    assert "data-recap-log-identity" in source
    assert "item.actual_minutes > 0" in source
    assert "querySelectorAll('[data-recap-log-identity]:checked')" in source
    assert ".today-recap-log" in css and "min-height:44px" in css


def test_recap_store_is_idempotent_account_scoped_bounded_and_newest_first(tmp_path):
    now = [1_000.0]
    store = TodayStore(tmp_path / "today.sqlite3", recap_limit=2, clock=lambda: now[0])
    first = {
        "session_id": "session-1",
        "items": [
            {"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42},
            {"identity": "pull:stackchain/api:8:", "estimate_minutes": None, "actual_minutes": 12},
        ],
    }

    saved = store.save_recap("Timmy", first["session_id"], first["items"])
    replay = store.save_recap("timmy", first["session_id"], first["items"])
    now[0] += 1
    store.save_recap("timmy", "session-2", [{"identity": "issue:r:2:", "estimate_minutes": 10, "actual_minutes": 8}])
    now[0] += 1
    store.save_recap("timmy", "session-3", [{"identity": "issue:r:3:", "estimate_minutes": 10, "actual_minutes": 15}])
    store.save_recap("alexander", "other", [{"identity": "issue:r:9:", "estimate_minutes": 5, "actual_minutes": 5}])

    assert saved == replay
    assert saved["session_id"] == "session-1"
    assert saved["estimated_minutes"] == 30
    assert saved["actual_minutes"] == 54
    assert [row["session_id"] for row in store.list_recaps("timmy")] == ["session-3", "session-2"]
    assert [row["session_id"] for row in store.list_recaps("alexander")] == ["other"]


@pytest.mark.parametrize(
    "items,message",
    [
        ([], "at least one"),
        ([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": -1}], "actual"),
        ([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": 1441}], "actual"),
        ([{"identity": "", "estimate_minutes": 5, "actual_minutes": 1}], "identity"),
    ],
)
def test_recap_store_rejects_invalid_or_unbounded_rows(tmp_path, items, message):
    store = TodayStore(tmp_path / "today.sqlite3")
    with pytest.raises(ValueError, match=message):
        store.save_recap("timmy", "session", items)


@pytest.mark.anyio
async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped(monkeypatch, tmp_path):
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
    monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))

    async def user():
        return {"id": 1, "login": "Timmy"}

    monkeypatch.setattr(main, "current_user", user)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
        await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
        headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
        payload = {"session_id": "mobile-session-1", "items": [
            {"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42}
        ]}
        created = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
        replay = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
        history = await client.get("/api/v1/today/recaps")

    assert created.status_code == replay.status_code == 200
    assert created.json() == replay.json()
    assert history.json()["recaps"] == [created.json()]
    assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"


@pytest.mark.anyio
async def test_recap_time_logging_requires_one_exact_single_use_fresh_authorization(
    monkeypatch, tmp_path
):
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
    monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
    calls = []

    async def user():
        return {"id": 1, "login": "Timmy"}

    async def log_time(identity, seconds):
        calls.append((identity, seconds))

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
    payload = {
        "session_id": "authorization-session",
        "items": [{
            "identity": "issue:stackchain/dashboard:589:",
            "estimate_minutes": 30,
            "actual_minutes": 42,
        }],
        "log_identities": ["issue:stackchain/dashboard:589:"],
    }
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
        await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
        headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
        missing = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
        history_before = await client.get("/api/v1/today/recaps")
        target = missing.json()["detail"]["target"]
        authorization = await client.post(
            "/api/v1/fresh-authorization",
            json={
                "access_token": "correct horse battery staple",
                "action": "log_recap_time",
                "target": target,
            },
            headers=headers,
        )
        grant = authorization.json()["grant"]
        changed = {**payload, "items": [{**payload["items"][0], "actual_minutes": 43}]}
        mismatched = await client.post(
            "/api/v1/today/recaps/log-time",
            json=changed,
            headers={**headers, "X-Step-Up-Grant": grant},
        )
        accepted = await client.post(
            "/api/v1/today/recaps/log-time",
            json=payload,
            headers={**headers, "X-Step-Up-Grant": grant},
        )
        replay = await client.post(
            "/api/v1/today/recaps/log-time",
            json=payload,
            headers={**headers, "X-Step-Up-Grant": grant},
        )

    assert missing.status_code == mismatched.status_code == replay.status_code == 428
    assert missing.json()["detail"]["action"] == "log_recap_time"
    assert target.startswith("today-recap:") and len(target) == 44
    assert history_before.json()["recaps"] == []
    assert accepted.status_code == 200
    assert calls == [("issue:stackchain/dashboard:589:", 42 * 60)]


@pytest.mark.anyio
async def test_recap_time_logging_journals_confirmed_ambiguous_and_retryable_outcomes(
    monkeypatch, tmp_path
):
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
    monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
    security_db = tmp_path / "security.sqlite3"
    monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(security_db))

    async def user():
        return {"id": 1, "login": "Timmy"}

    async def log_time(identity, _seconds):
        if ":590:" in identity:
            raise httpx.ReadTimeout("response was lost after sending")
        if ":591:" in identity:
            raise httpx.ConnectError("request was not sent")

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
    payload = {
        "session_id": "private-recap-session",
        "items": [
            {"identity": f"issue:stackchain/dashboard:{number}:", "estimate_minutes": 10, "actual_minutes": minutes}
            for number, minutes in [(589, 11), (590, 12), (591, 13)]
        ],
        "log_identities": [
            f"issue:stackchain/dashboard:{number}:" for number in (589, 590, 591)
        ],
    }
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
        await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
        headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
        challenge = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
        authorization = await client.post(
            "/api/v1/fresh-authorization",
            json={
                "access_token": "correct horse battery staple",
                "action": "log_recap_time",
                "target": challenge.json()["detail"]["target"],
            },
            headers=headers,
        )
        response = await client.post(
            "/api/v1/today/recaps/log-time",
            json=payload,
            headers={**headers, "X-Step-Up-Grant": authorization.json()["grant"]},
        )
        events = (await client.get("/api/v1/security-events")).json()["events"]

    assert response.json()["time_logs"] == [
        {"identity": "issue:stackchain/dashboard:589:", "status": "logged"},
        {"identity": "issue:stackchain/dashboard:590:", "status": "verify"},
        {"identity": "issue:stackchain/dashboard:591:", "status": "retry"},
    ]
    time_events = [event for event in events if event["kind"] == "gitea_time_logged"]
    assert {(event["target"], event["status"]) for event in time_events} == {
        ("stackchain/dashboard#590", "pending"),
        ("stackchain/dashboard#589", "completed"),
    }
    persisted = security_db.read_bytes()
    assert b"private-recap-session" not in persisted
    assert challenge.json()["detail"]["target"].encode() not in persisted


@pytest.mark.anyio
async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurrency(
    monkeypatch, tmp_path
):
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
    monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
    active = 0
    peak = 0
    attempted = []
    first_wave_started = asyncio.Event()
    release_first_wave = asyncio.Event()

    async def user():
        return {"id": 1, "login": "Timmy"}

    async def log_time(identity, _seconds):
        nonlocal active, peak
        active += 1
        peak = max(peak, active)
        attempted.append(identity)
        if len(attempted) == 3:
            first_wave_started.set()
        try:
            await release_first_wave.wait()
        finally:
            active -= 1

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
    identities = [f"issue:stackchain/dashboard:{number}:" for number in range(607, 612)]
    payload = {
        "session_id": "concurrent-recap-session",
        "items": [
            {"identity": identity, "estimate_minutes": 10, "actual_minutes": 11}
            for identity in identities
        ],
        "log_identities": identities,
    }
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
        await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
        headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
        grant = await recap_time_grant(client, headers, payload)
        request = asyncio.create_task(
            client.post(
                "/api/v1/today/recaps/log-time",
                json=payload,
                headers={**headers, "X-Step-Up-Grant": grant},
            )
        )
        await asyncio.wait_for(first_wave_started.wait(), timeout=1)
        await asyncio.sleep(0)
        assert active == 3
        assert peak == 3
        assert len(attempted) == 3
        assert not request.done()
        release_first_wave.set()
        response = await request

    assert response.status_code == 200
    assert peak == 3
    assert set(attempted) == set(identities)
    assert response.json()["time_logs"] == [
        {"identity": identity, "status": "logged"} for identity in identities
    ]


@pytest.mark.anyio
async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypatch, tmp_path):
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
    monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
    calls = []

    async def user():
        return {"id": 1, "login": "Timmy"}

    async def log_time(identity, seconds):
        calls.append((identity, seconds))

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
    payload = {
        "session_id": "mobile-session-1",
        "items": [
            {"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42},
            {"identity": "pull:stackchain/dashboard:586:", "estimate_minutes": 10, "actual_minutes": 0},
        ],
        "log_identities": ["issue:stackchain/dashboard:587:"],
    }
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
        await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
        headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
        first_grant = await recap_time_grant(client, headers, payload)
        created = await client.post(
            "/api/v1/today/recaps/log-time",
            json=payload,
            headers={**headers, "X-Step-Up-Grant": first_grant},
        )
        replay_grant = await recap_time_grant(client, headers, payload)
        replay = await client.post(
            "/api/v1/today/recaps/log-time",
            json=payload,
            headers={**headers, "X-Step-Up-Grant": replay_grant},
        )

    assert created.status_code == replay.status_code == 200
    assert calls == [("issue:stackchain/dashboard:587:", 42 * 60)]
    assert created.json()["time_logs"] == [{
        "identity": "issue:stackchain/dashboard:587:", "status": "logged"
    }]
    assert replay.json()["time_logs"] == [{
        "identity": "issue:stackchain/dashboard:587:", "status": "logged"
    }]
    assert created.headers["cache-control"] == "no-store"


@pytest.mark.anyio
async def test_ambiguous_time_log_failure_is_not_reposted_automatically(monkeypatch, tmp_path):
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
    monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
    calls = 0

    async def user():
        return {"id": 1, "login": "Timmy"}

    async def log_time(_identity, _seconds):
        nonlocal calls
        calls += 1
        raise httpx.ReadTimeout("response was lost after sending")

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
    payload = {
        "session_id": "ambiguous-session",
        "items": [{"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42}],
        "log_identities": ["issue:stackchain/dashboard:587:"],
    }
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
        await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
        headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
        first_grant = await recap_time_grant(client, headers, payload)
        first = await client.post(
            "/api/v1/today/recaps/log-time",
            json=payload,
            headers={**headers, "X-Step-Up-Grant": first_grant},
        )
        replay_grant = await recap_time_grant(client, headers, payload)
        replay = await client.post(
            "/api/v1/today/recaps/log-time",
            json=payload,
            headers={**headers, "X-Step-Up-Grant": replay_grant},
        )

    assert calls == 1
    assert first.json()["time_logs"] == replay.json()["time_logs"] == [{
        "identity": "issue:stackchain/dashboard:587:", "status": "verify"
    }]


@pytest.mark.anyio
async def test_recap_time_logging_rejects_noncanonical_and_zero_targets_before_upstream(monkeypatch, tmp_path):
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
    monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
    monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
    monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
    calls = []

    async def user():
        return {"id": 1, "login": "Timmy"}

    async def log_time(identity, seconds):
        calls.append((identity, seconds))

    monkeypatch.setattr(main, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
        await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
        headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
        malformed = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={
            "session_id": "bad-target",
            "items": [{"identity": "issue:https://evil.test:1:", "estimate_minutes": 5, "actual_minutes": 5}],
            "log_identities": ["issue:https://evil.test:1:"],
        })
        zero = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={
            "session_id": "zero-target",
            "items": [{"identity": "issue:stackchain/dashboard:1:", "estimate_minutes": 5, "actual_minutes": 0}],
            "log_identities": ["issue:stackchain/dashboard:1:"],
        })
        history = await client.get("/api/v1/today/recaps")

    assert malformed.status_code == zero.status_code == 422
    assert calls == []
    assert history.json()["recaps"] == []


def test_recap_controller_calculates_variance_validates_corrections_and_clears_after_save():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const calls=[]; let cleared=0;
const recap=createRecap({{
  save:payload=>{{calls.push(payload); return Promise.resolve(payload);}},
  clear:()=>{{cleared += 1;}},
  makeId:()=> 'session-fixed',
}});
const draft=recap.begin([
  {{identity:'issue:r:1:',elapsed_ms:42*60000}},
  {{identity:'pull:r:2:',elapsed_ms:12*60000}},
], {{'issue:r:1:':30}});
const invalidLow=recap.correct('issue:r:1:',-1);
const invalidHigh=recap.correct('issue:r:1:',1441);
const corrected=recap.correct('issue:r:1:',45);
recap.save().then(saved=>process.stdout.write(JSON.stringify({{
  draft,invalidLow,invalidHigh,corrected,saved,calls,cleared
}}))).catch(error=>{{console.error(error);process.exit(1);}});
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    output = json.loads(result.stdout)
    assert output["draft"]["estimated_minutes"] == 30
    assert output["draft"]["actual_minutes"] == 54
    assert output["draft"]["variance_minutes"] == 24
    assert output["invalidLow"] is False
    assert output["invalidHigh"] is False
    assert output["corrected"] is True
    assert output["calls"][0]["session_id"] == "session-fixed"
    assert output["calls"][0]["items"][0]["actual_minutes"] == 45
    assert output["cleared"] == 1


def test_recap_controller_logs_only_selected_nonzero_items_and_retains_failed_draft():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const calls=[]; let fail=true; let cleared=0;
const recap=createRecap({{
  save:payload=>Promise.resolve(payload),
  saveAndLog:payload=>{{calls.push(payload); return fail ? Promise.reject(new Error('offline')) : Promise.resolve({{
    time_logs:payload.log_identities.map(identity=>({{identity,status:'logged'}}))
  }});}},
  clear:()=>{{cleared += 1;}}, makeId:()=> 'log-session',
}});
recap.begin([
  {{identity:'issue:stackchain/dashboard:587:',elapsed_ms:42*60000}},
  {{identity:'pull:stackchain/dashboard:586:',elapsed_ms:0}},
]);
(async()=>{{
  let error='';
  try {{ await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }}
  catch (caught) {{ error=caught.message; }}
  const retained=recap.snapshot(); fail=false;
  const result=await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']);
  process.stdout.write(JSON.stringify({{error,retained,result,calls,cleared,after:recap.snapshot()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert run.returncode == 0, run.stderr
    output = json.loads(run.stdout)
    assert output["error"] == "offline"
    assert output["retained"]["session_id"] == "log-session"
    assert output["calls"][0]["log_identities"] == ["issue:stackchain/dashboard:587:"]
    assert output["result"]["time_logs"] == [{
        "identity": "issue:stackchain/dashboard:587:", "status": "logged"
    }]
    assert output["cleared"] == 1
    assert output["after"] is None


def test_recap_controller_exposes_per_item_partial_logging_results_for_retry():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const recap=createRecap({{
  save:payload=>Promise.resolve(payload), clear:()=>{{}}, makeId:()=> 'partial-session',
  saveAndLog:payload=>Promise.resolve({{time_logs:[
    {{identity:payload.log_identities[0],status:'logged'}},
    {{identity:payload.log_identities[1],status:'retry'}},
  ]}}),
}});
recap.begin([
  {{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}},
  {{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}},
]);
recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:'])
  .then(()=>{{throw new Error('expected retry');}})
  .catch(error=>process.stdout.write(JSON.stringify({{message:error.message,draft:recap.snapshot()}})));
"""
    run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert run.returncode == 0, run.stderr
    output = json.loads(run.stdout)
    assert output["message"] == "1 time entry logged; 1 needs retry."
    assert output["draft"]["time_logs"] == {
        "issue:stackchain/dashboard:587:": "logged",
        "pull:stackchain/dashboard:586:": "retry",
    }


def test_recap_partial_time_logging_intent_survives_reload_until_every_item_is_logged():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map(); const calls=[]; let attempt=0; let cleared=0;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const saveAndLog=payload=>{{
  calls.push(payload.log_identities); attempt += 1;
  return Promise.resolve({{time_logs:payload.log_identities.map((identity,index)=>({{
    identity,status:attempt === 1 && index === 1 ? 'retry' : 'logged'
  }}))}});
}};
const options={{save:payload=>Promise.resolve(payload),saveAndLog,clear:()=>{{cleared += 1;}},storage,getLogin:()=> 'timmy'}};
const first=createRecap({{...options,makeId:()=> 'durable-log'}});
first.begin([
  {{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}},
  {{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}},
]);
(async()=>{{
  try {{ await first.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }} catch (_error) {{}}
  const restored=createRecap(options);
  const pending=restored.snapshot();
  await restored.saveAndLog(['pull:stackchain/dashboard:586:']);
  process.stdout.write(JSON.stringify({{pending,calls,cleared,after:restored.snapshot(),stored:[...values.keys()]}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert run.returncode == 0, run.stderr
    output = json.loads(run.stdout)
    assert output["pending"]["log_identities"] == [
        "issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"
    ]
    assert output["pending"]["time_logs"]["pull:stackchain/dashboard:586:"] == "retry"
    assert output["calls"] == [
        ["issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"],
        ["pull:stackchain/dashboard:586:"],
    ]
    assert output["cleared"] == 1
    assert output["after"] is None
    assert output["stored"] == []


def test_recap_feedback_uses_work_metadata_and_reports_per_item_variance():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const rows=createRecap.feedbackRows({{
  items:[
    {{identity:'issue:stackchain/dashboard:583:',estimate_minutes:30,actual_minutes:52}},
    {{identity:'pull:stackchain/api:9:',estimate_minutes:null,actual_minutes:12}},
  ]
}}, identity => identity.startsWith('issue:') ? {{
  title:'Turn recap feedback into a plan', key:'stackchain/dashboard#583'
}} : null);
process.stdout.write(JSON.stringify(rows));
"""
    run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert run.returncode == 0, run.stderr
    assert json.loads(run.stdout) == [
        {
            "identity": "issue:stackchain/dashboard:583:",
            "label": "Turn recap feedback into a plan",
            "context": "stackchain/dashboard#583",
            "estimate_minutes": 30,
            "actual_minutes": 52,
            "variance_minutes": 22,
        },
        {
            "identity": "pull:stackchain/api:9:",
            "label": "pull:stackchain/api:9:",
            "context": "Work details unavailable",
            "estimate_minutes": None,
            "actual_minutes": 12,
            "variance_minutes": None,
        },
    ]


def test_recap_controller_restores_corrected_draft_with_stable_retry_identity():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values = new Map();
const storage = {{
  getItem:key => values.has(key) ? values.get(key) : null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const options = {{
  save:payload => Promise.resolve(payload), clear:() => {{}}, storage,
  getLogin:() => ' Timmy ', makeId:() => 'stable-session',
}};
const first = createRecap(options);
first.begin([{{identity:'issue:r:1:',elapsed_ms:12*60000}}], {{'issue:r:1:':20}});
first.correct('issue:r:1:', 17);
const restored = createRecap({{...options, makeId:() => 'different-session'}}).snapshot();
process.stdout.write(JSON.stringify({{restored, keys:[...values.keys()]}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    output = json.loads(result.stdout)
    assert output["restored"] == {
        "session_id": "stable-session",
        "items": [{"identity": "issue:r:1:", "estimate_minutes": 20, "actual_minutes": 17}],
        "estimated_minutes": 20,
        "actual_minutes": 17,
        "variance_minutes": -3,
    }
    assert output["keys"] == ["stackchain.today-recap-draft.v1.timmy"]


def test_recap_controller_removes_corrupt_persisted_draft():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-draft.v1.timmy';
const values=new Map([[key, '{{not-json']]);
const storage={{
  getItem:key => values.has(key) ? values.get(key) : null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{draft:recap.snapshot(),hasKey:values.has(key)}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {"draft": None, "hasKey": False}


def test_recap_controller_restores_after_account_confirmation():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map([['stackchain.today-recap-draft.v1.timmy', JSON.stringify({{
  session_id:'offline-session', items:[{{identity:'issue:r:2:',estimate_minutes:null,actual_minutes:9}}]
}})]]);
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let login='';
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login}});
const before=recap.snapshot(); login='timmy'; const didRestore=recap.restore(); const after=recap.snapshot();
process.stdout.write(JSON.stringify({{before,didRestore,after}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "before": None,
        "didRestore": True,
        "after": {
            "session_id": "offline-session",
            "items": [{"identity": "issue:r:2:", "estimate_minutes": None, "actual_minutes": 9}],
            "estimated_minutes": 0,
            "actual_minutes": 9,
            "variance_minutes": 9,
        },
    }


def test_recap_controller_never_exposes_draft_after_account_switch():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let login='timmy';
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login,makeId:()=> 'timmy-session'}});
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
login='alexander';
process.stdout.write(JSON.stringify({{visible:recap.snapshot(),timmyDraft:values.has('stackchain.today-recap-draft.v1.timmy')}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {"visible": None, "timmyDraft": True}


def test_recap_controller_keeps_failed_save_and_clears_only_after_confirmation():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-draft.v1.timmy'; const values=new Map(); let cleared=0; let fail=true;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const recap=createRecap({{
  save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve(payload),
  clear:()=>{{cleared += 1;}}, storage, getLogin:()=> 'timmy', makeId:()=> 'retry-session'
}});
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
(async()=>{{
  try {{ await recap.save(); }} catch (_error) {{}}
  const afterFailure={{draft:recap.snapshot(),stored:values.has(key),cleared}};
  fail=false; await recap.save();
  process.stdout.write(JSON.stringify({{afterFailure,afterSuccess:{{draft:recap.snapshot(),stored:values.has(key),cleared}}}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    output = json.loads(result.stdout)
    assert output["afterFailure"]["draft"]["session_id"] == "retry-session"
    assert output["afterFailure"]["stored"] is True
    assert output["afterFailure"]["cleared"] == 0
    assert output["afterSuccess"] == {"draft": None, "stored": False, "cleared": 1}


def test_recap_controller_returns_actuals_for_replanning_only_after_save_confirmation():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
let fail=true;
const recap=createRecap({{
  save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve({{saved:true}}),
  clear:()=>{{}}, makeId:()=> 'replan-session'
}});
recap.begin([
  {{identity:'issue:r:1:',elapsed_ms:52*60000}},
  {{identity:'issue:r:2:',elapsed_ms:7*60000}},
], {{'issue:r:1:':30,'issue:r:2:':7}});
(async()=>{{
  let failed;
  try {{ await recap.saveForReplan(); }} catch (error) {{ failed=error.message; }}
  const retained=recap.snapshot();
  fail=false;
  const confirmed=await recap.saveForReplan();
  process.stdout.write(JSON.stringify({{failed,retained,confirmed,after:recap.snapshot()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert run.returncode == 0, run.stderr
    output = json.loads(run.stdout)
    assert output["failed"] == "offline"
    assert output["retained"]["session_id"] == "replan-session"
    assert output["confirmed"] == {
        "result": {"saved": True},
        "actual_minutes": {"issue:r:1:": 52, "issue:r:2:": 7},
    }
    assert output["after"] is None


def test_recap_controller_restores_account_scoped_pending_replan_until_completed():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let login='timmy';
const options={{save:payload=>Promise.resolve({{session_id:payload.session_id}}),clear:()=>{{}},storage,getLogin:()=>login}};
const first=createRecap({{...options,makeId:()=> 'saved-session'}});
first.begin([{{identity:'issue:r:1:',elapsed_ms:52*60000}}], {{'issue:r:1:':30}});
(async()=>{{
  await first.saveForReplan();
  const afterSave=first.pendingReplan();
  const restored=createRecap(options).pendingReplan();
  login='alexander';
  const otherAccount=createRecap(options).pendingReplan();
  login='timmy';
  const retainedAfterCancel=createRecap(options).pendingReplan();
  createRecap(options).completeReplan();
  const afterComplete=createRecap(options).pendingReplan();
  process.stdout.write(JSON.stringify({{afterSave,restored,otherAccount,retainedAfterCancel,afterComplete,keys:[...values.keys()]}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
    run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert run.returncode == 0, run.stderr
    output = json.loads(run.stdout)
    pending = {
        "session_id": "saved-session",
        "actual_minutes": {"issue:r:1:": 52},
    }
    assert output == {
        "afterSave": pending,
        "restored": pending,
        "otherAccount": None,
        "retainedAfterCancel": pending,
        "afterComplete": None,
        "keys": [],
    }


def test_recap_controller_removes_malformed_or_unbounded_pending_replan():
    script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-handoff.v1.timmy';
const values=new Map([[key, JSON.stringify({{session_id:'saved',actual_minutes:Object.fromEntries(
  Array.from({{length:21}}, (_,index)=>['issue:r:' + index + ':', 10])
)}})]]);
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{pending:recap.pendingReplan(),hasKey:values.has(key)}}));
"""
    run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert run.returncode == 0, run.stderr
    assert json.loads(run.stdout) == {"pending": None, "hasKey": False}


@pytest.mark.anyio
async def test_dashboard_renders_mobile_today_recap_flow():
    html = main.FRONTEND_BUILD.dashboard_html
    css = (ROOT / "frontend" / "dashboard.css").read_text()
    timer = (ROOT / "frontend" / "today-timer.js").read_text()
    recap_source = TODAY_RECAP.read_text()
    dashboard = (ROOT / "frontend" / "dashboard.js").read_text()

    assert 'id="today-recap-sheet" role="dialog"' in html
    assert 'id="today-recap-history"' in html
    assert 'static/today-recap.js' in main.FRONTEND_BUILD.page_sources
    assert "timer.recapEntries()" in recap_source
    assert "isActive: () => workSession.checkpointed()" in dashboard
    assert "openTodayRecapAfterSession(view" in recap_source
    assert "api/v1/today/recaps" in recap_source
    assert "storage:localStorage" in recap_source
    assert "() => planningOwnerLogin" in dashboard
    assert "recapEntries()" in timer and "clearRecap()" in timer
    assert ".today-recap-header button { min-height:44px;" in css
    assert ".today-recap-actions button { min-height:44px;" in css
    assert "overflow-x:hidden" in css
    assert "Save recap &amp; wrap up" in html
    assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
    assert 'class="today-recap-variance"' in recap_source
    assert "await recap.saveForReplan()" in recap_source
    assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap_source
    assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
    assert "todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)" in dashboard
    assert ".today-recap-row { grid-template-columns:1fr; }" in css


@pytest.mark.anyio
async def test_dashboard_resumes_and_explicitly_discards_pending_recap_feedback():
    html = main.FRONTEND_BUILD.dashboard_html
    dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
    recap_source = TODAY_RECAP.read_text()

    assert 'id="discard-recap-replan"' in html
    assert "todayRecapView.pendingReplan()?.actual_minutes" in dashboard
    assert "todayRecapView.completeReplan();" in dashboard
    assert "todayRecapView.discardReplan();" in dashboard
    assert "if (capacityAware && !todayWork.replacePlanning(plan)) return false;" in dashboard
    assert dashboard.index("todayWork.replacePlanning(plan)") < dashboard.index("todayRecapView.completeReplan();")
    assert "pendingReplan:recap.pendingReplan" in recap_source
    assert "discardReplan:recap.discardReplan" in recap_source
