import json
import subprocess
from pathlib import Path


ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "following.js"


def run(script: str) -> dict:
    harness = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{ renders:[], counts:[], opened:[], requests:[] }};
const feature = createFollowing({{
  fetchJson: async path => {{
    state.requests.push(path);
    return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42',has_unseen_change:false}}]}};
  }},
  render: snapshot => state.renders.push(snapshot),
  onCount: count => state.counts.push(count),
  onOpen: item => state.opened.push(item),
}});
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
    return json.loads(completed.stdout)


def test_following_loads_account_collection_and_opens_existing_preview():
    result = run("""
await feature.load();
feature.open(0);
process.stdout.write(JSON.stringify(state));
""")

    assert result["requests"] == ["api/v1/following"]
    assert result["counts"] == [0]
    assert result["renders"][-1]["status"] == "ready"
    assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
    assert result["opened"] == [{
        "repository": "stackchain/api",
        "number": 42,
        "title": "Quiet issue",
        "state": "open",
        "updated_at": "2026-08-23T03:00:00Z",
        "url": "https://forge.example/issue/42",
        "has_unseen_change": False,
        "kind": "issue",
        "following": True,
    }]


def test_following_opens_pull_requests_in_the_pull_workspace():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const opened=[];
const feature=createFollowing({{
  fetchJson:async () => ({{revision:1,items:[{{
    repository:'stackchain/api',kind:'pull',number:84,title:'Typed review',state:'open',
    updated_at:'2026-08-23T05:00:00Z',url:'https://forge.example/pulls/84',has_unseen_change:true
  }}]}}),
  onOpen:async item => opened.push(item), onAcknowledge:async () => {{}},
}});
(async () => {{
  await feature.load();
  await feature.startReview();
  process.stdout.write(JSON.stringify({{opened,session:feature.session()}}));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)

    assert result["opened"][0]["kind"] == "pull"
    assert result["opened"][0]["following"] is True
    assert result["session"]["items"][0]["kind"] == "pull"


def test_following_route_loads_authoritative_state_before_starting_review():
    following = MODULE.read_text()
    start = following.index("async route()")
    route = following[start:following.index("open()", start)]

    assert "await feature.load().then(feature.startReview)" in route
    assert ".catch(() => false)" in route
    assert "show();" in route


def test_dashboard_dispatches_following_route_to_changed_first_review():
    dashboard = (ROOT / "frontend" / "dashboard.js").read_text()

    start = dashboard.index("async function openWorkQueueRoute")
    route_handler = dashboard[start:dashboard.index("qs('#work-milestone-filter')", start)]
    assert "if (filter === 'following')" in route_handler
    assert "return followingQueue.route();" in route_handler
    assert route_handler.index("followingQueue.route") < route_handler.index("selectWorkQueue")


def test_following_preview_wires_keep_for_later_action_and_retryable_failure():
    html = (ROOT / "frontend" / "index.html").read_text()
    dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
    following = MODULE.read_text()

    assert 'id="keep-following-for-later"' in html
    assert 'id="keep-following-status"' in html
    assert "followingQueue.preview(state)" in dashboard
    assert "query('#close-search-preview').click()" in following
    assert "state?.item?.following !== true" in following
    assert "feature.keepForLater(activeReviewItem)" in following
    assert "Kept for later" in following
    assert "Could not keep this change for later" in following
    assert "changeRevision(item, 'keep')" in following
    assert "keepForLater:feature.keepForLater" in following


def test_following_typed_identity_prevents_issue_pull_collisions():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const acknowledged=[];
const items=['issue','pull'].map(kind => ({{
  repository:'stackchain/api',kind,number:84,title:kind,state:'closed',
  updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true
}}));
const feature=createFollowing({{
  fetchJson:async () => ({{revision:1,items}}),
  onOpen:async () => {{}}, onAcknowledge:async item => acknowledged.push(item.kind),
}});
(async () => {{
  await feature.load();
  await feature.previewLoaded({{...items[1]}});
  feature.retire(items[1]);
  process.stdout.write(JSON.stringify({{acknowledged,items:feature.items()}}));
}})().catch(error => {{console.error(error);process.exit(1);}});
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)

    assert result["acknowledged"] == ["pull"]
    assert [(item["kind"], item["has_unseen_change"]) for item in result["items"]] == [
        ("issue", True)
    ]


def test_following_opens_explicitly_but_never_becomes_work_recommendation():
    launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
    script = f"""
const createLauncher = require({json.dumps(str(launcher))});
const calls = [];
const feature = createLauncher({{
  getCounts:() => ({{following:7}}),
  openFollowing:() => {{ calls.push('following'); return 'opened-following'; }},
  selectFilter:name => calls.push(name), firstAction:() => null,
  announce:() => {{}}, openFindWork:() => calls.push('find'),
}});
process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}}));
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)
    assert result == {
        "opened": "opened-following",
        "recommended": {"name": "find", "count": 0, "label": "Find Work"},
        "calls": ["following"],
    }


def test_following_counts_unseen_changes_and_acknowledges_after_preview_loads():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{counts:[], opened:[], acknowledged:[], renders:[]}};
let finishPreview;
const feature = createFollowing({{
  fetchJson:async () => ({{revision:4,items:[
    {{repository:'stackchain/api',number:42,title:'Changed',state:'open',updated_at:'2026-08-23T04:00:00Z',url:'https://forge/42',has_unseen_change:true}},
    {{repository:'stackchain/api',number:43,title:'Quiet',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge/43',has_unseen_change:false}}
  ]}}),
  render:value => state.renders.push(value),
  onCount:value => state.counts.push(value),
  onOpen:item => new Promise(resolve => {{ finishPreview=() => {{ state.opened.push(item.number); resolve(); }}; }}),
  onAcknowledge:async item => state.acknowledged.push(item.updated_at),
}});
(async () => {{
  await feature.load();
  const opening=feature.open(0);
  await new Promise(resolve => setImmediate(resolve));
  state.before={{acknowledged:[...state.acknowledged],counts:[...state.counts]}};
  finishPreview();
  await opening;
  process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)
    assert result["before"] == {"acknowledged": [], "counts": [1]}
    assert result["opened"] == [42]
    assert result["acknowledged"] == ["2026-08-23T04:00:00Z"]
    assert result["counts"] == [1, 0]
    assert result["renders"][-1]["items"][0]["has_unseen_change"] is False


def test_following_review_captures_only_current_unseen_changes_in_display_order():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{opened:[], acknowledged:[]}};
const feature = createFollowing({{
  fetchJson:async () => ({{revision:7,items:[
    {{repository:'stackchain/api',number:42,title:'Newest',state:'open',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
    {{repository:'stackchain/web',number:9,title:'Also changed',state:'open',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}},
    {{repository:'stackchain/api',number:41,title:'Quiet',state:'open',updated_at:'2026-08-22T05:00:00Z',has_unseen_change:false}}
  ]}}),
  onOpen:async item => state.opened.push(item.number),
  onAcknowledge:async item => state.acknowledged.push(item.number),
}});
(async () => {{
  await feature.load();
  const started=await feature.startReview();
  process.stdout.write(JSON.stringify({{started,session:feature.session(),state}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)

    assert result["started"] is True
    assert [item["number"] for item in result["session"]["items"]] == [42, 9]
    assert all(item["kind"] == "issue" for item in result["session"]["items"])
    assert all(item["following"] is True for item in result["session"]["items"])
    assert result["session"]["more"] is False
    assert result["state"] == {"opened": [42], "acknowledged": [42]}


def test_following_review_acknowledges_each_loaded_revision_and_reports_completion():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{acknowledged:[], renders:[], counts:[]}};
const items=[
  {{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
  {{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
  fetchJson:async () => ({{revision:7,items}}),
  render:value => state.renders.push(value), onCount:value => state.counts.push(value),
  onOpen:async () => {{}}, onAcknowledge:async item => state.acknowledged.push(item.number),
}});
(async () => {{
  await feature.load();
  await feature.startReview();
  await feature.previewLoaded(feature.session().items[1]);
  process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)

    assert result["acknowledged"] == [42, 9]
    assert result["counts"][-1] == 0
    assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0}


def test_following_keep_for_later_preserves_revision_and_advances_without_looping():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{opened:[], acknowledged:[], kept:[], counts:[]}};
const items=[
  {{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
  {{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
  fetchJson:async () => ({{revision:7,items}}),
  onOpen:async item => state.opened.push(item.number),
  onAcknowledge:async item => state.acknowledged.push(item.number),
  onKeep:async item => state.kept.push(item.number),
  onCount:value => state.counts.push(value),
}});
(async () => {{
  await feature.load();
  await feature.startReview();
  const kept = await feature.keepForLater(feature.session().items[0]);
  process.stdout.write(JSON.stringify({{
    kept, session:feature.session(), items:feature.items(), state
  }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)

    assert result["kept"]["number"] == 9
    assert [item["number"] for item in result["session"]["items"]] == [9]
    assert result["items"][0]["has_unseen_change"] is True
    assert result["state"] == {
        "opened": [42, 9], "acknowledged": [42, 9], "kept": [42], "counts": [2, 1, 2, 1]
    }


def test_following_review_notifies_prepare_today_when_final_revision_is_loaded():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const completed = [];
const items=[
  {{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
  {{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
  fetchJson:async () => ({{revision:7,items}}),
  onOpen:async () => {{}},
  onAcknowledge:async () => {{}},
  onReviewComplete:() => completed.push('following'),
}});
(async () => {{
  await feature.load();
  await feature.startReview();
  await feature.previewLoaded(feature.session().items[1]);
  const beforeFinish = completed.slice();
  feature.finishReview();
  feature.finishReview();
  process.stdout.write(JSON.stringify({{beforeFinish, completed}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""

    assert json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout) == {"beforeFinish": [], "completed": ["following"]}


def test_following_retirement_removes_closed_item_and_keeps_review_moving():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{renders:[], counts:[], completed:[]}};
const items=[
  {{repository:'stackchain/api',number:42,title:'Closed first',state:'closed',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
  {{repository:'stackchain/web',number:9,title:'Closed second',state:'closed',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
  fetchJson:async () => ({{revision:7,items}}),
  render:value => state.renders.push(value), onCount:value => state.counts.push(value),
  onOpen:async () => {{}}, onAcknowledge:async () => {{}},
  onReviewComplete:() => state.completed.push('following'),
}});
(async () => {{
  await feature.load();
  await feature.startReview();
  const first=feature.session().items[0];
  const next=feature.retire(first);
  await feature.previewLoaded(next);
  const final=feature.retire(next);
  const handoff=feature.finishReview();
  process.stdout.write(JSON.stringify({{
    next:next.number, final, handoff, items:feature.items(), session:feature.session(), state
  }}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)

    assert result["next"] == 9
    assert result["final"] is None
    assert result["handoff"] is True
    assert result["items"] == []
    assert result["session"] is None
    assert result["state"]["counts"][-1] == 0
    assert result["state"]["completed"] == ["following"]


def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
    script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{acknowledged:[], counts:[]}};
let loads=0;
const original=[
  {{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
  {{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
];
const refreshed=[
  {{...original[0],has_unseen_change:false}},
  {{...original[1],updated_at:'2026-08-23T07:00:00Z',has_unseen_change:true}}
];
const feature = createFollowing({{
  fetchJson:async () => ({{revision:++loads,items:loads === 1 ? original : refreshed}}),
  onCount:value => state.counts.push(value), onOpen:async () => {{}},
  onAcknowledge:async item => state.acknowledged.push(item.updated_at),
}});
(async () => {{
  await feature.load();
  await feature.startReview();
  const captured=feature.session().items[1];
  await feature.load();
  const cleared=await feature.previewLoaded(captured);
  process.stdout.write(JSON.stringify({{cleared,state}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = json.loads(subprocess.run(
        ["node", "-e", script], text=True, capture_output=True, check=True
    ).stdout)

    assert result == {
        "cleared": False,
        "state": {
            "acknowledged": ["2026-08-23T06:00:00Z"],
            "counts": [2, 1, 1],
        },
    }


def test_following_review_controls_are_wired_into_the_phone_preview_flow():
    html = (ROOT / "frontend" / "index.html").read_text()
    css = (ROOT / "frontend" / "dashboard.css").read_text()
    following = MODULE.read_text()
    dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
    service_worker = (ROOT / "frontend" / "service-worker.js").read_text()

    assert 'id="review-following"' in html
    assert '>Review new activity</button>' in html
    assert ".following-review-action" in css
    assert "min-height:44px" in css
    assert "query('#review-following').addEventListener('click'" in following
    assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
    assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
    assert "changeRevision(item, 'seen')" in following
    assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
    assert "followingQueue.returnToFollowing()" in dashboard
    assert "'Back to Following'" in dashboard
    assert "if (searchPreviewReturnKind === 'following')" in dashboard
    assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
    assert "stackchain-dashboard-shell-v136" in service_worker


def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
    following = MODULE.read_text()
    dashboard = (ROOT / "frontend" / "dashboard.js").read_text()

    assert "function attachFollowing(onOpen, hooks = {})" in following
    assert "hooks.onCount?.(count, feature.items())" in following
    assert "hooks.onStatus?.(state.status)" in following
    assert "onReviewComplete:hooks.onReviewComplete" in following
    assert "review:feature.startReview" in following
    assert "const completed = feature.finishReview()" in following
    assert "if (completed) return 'completed-following'" in following
    assert "followingQueue.load().catch(() => {})" in dashboard
    assert "mobileQueueCounts.following = count" in dashboard
    assert "mobileQueueCounts.followingUnavailable = status === 'error'" in dashboard
    assert "mobilePreparationItems.following = items.filter" in dashboard
    assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in dashboard
    assert "mobileStartDay.completePhase('following')" in dashboard
