comicbox.online_session

[docs] module comicbox.online_session

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
"""
OnlineSession — Codex-facing API for batch online metadata tagging.

Wraps the existing Comicbox.run_online_lookup() per-file flow with a
Codex-friendly surface:

- Per-source credential validation up front (fail-fast).
- Direct :class:`MatchMode` API (no string alias layer; ``ASK`` is
  rejected since the session has no built-in prompt resolver for it).
- One programmatic PromptHandler per session that the matcher calls
  whenever it would otherwise hit the CLI questionary prompt.
- Event stream via on_event= for UI feedback.
- Cancellation token: callers stop the batch between files.

The internal matcher / selector / rate-limit machinery is untouched;
this module is a façade.
"""

from __future__ import annotations

import threading
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeAlias, runtime_checkable

from comicbox.box import Comicbox
from comicbox.config import get_config
from comicbox.config.settings import (
    MatchMode,
    OnlineAuthSettings,
    OnlineLookupSettings,
    OnlineSourceCredentials,
    Prompts,
)
from comicbox.events import FileError, PromptDeferred, PromptResolvedFromCache

# OnlineConfigurationError keeps its historical comicbox.online_session
# import path; the definition lives in comicbox.exceptions so it shares
# the ComicboxError base.
from comicbox.exceptions import OnlineConfigurationError, OnlineLookupAbortedError

# Re-exported so batch callers get the run estimator off the same Codex-facing
# façade as the session it estimates; the implementation lives in
# comicbox.online_estimate.
from comicbox.online_estimate import (
    SOURCE_RATE_PER_MINUTE,
    RunEstimate,
    estimate_run,
)

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable, Iterator, Sequence
    from pathlib import Path

    from comicbox.config.settings import ComicboxSettings
    from comicbox.events import EventHandler
    from comicbox.formats.base.online.profile import Candidate, ComicProfile
    from comicbox.formats.base.online.selector import (
        SelectorAction,
        SelectorContext,
        SelectorResult,
    )


__all__ = (
    "SOURCE_RATE_PER_MINUTE",
    "BatchedPromptHandler",
    "DeferredPrompt",
    "MatchMode",
    "OnlineConfigurationError",
    "OnlineCredentials",
    "OnlinePrompt",
    "OnlineResult",
    "OnlineSession",
    "PromptHandler",
    "PromptResponse",
    "RunEstimate",
    "SourceName",
    "estimate_run",
)


# --- public types -----------------------------------------------------------


SourceName: TypeAlias = Literal["metron", "comicvine"]

_KNOWN_SOURCES: frozenset[str] = frozenset({"metron", "comicvine"})


@dataclass(frozen=True, slots=True)
class OnlineCredentials:
    """Credentials for the online sources Codex may enable."""

    metron_user: str | None = None
    metron_password: str | None = None
    metron_url: str | None = None
    comicvine_key: str | None = None
    comicvine_url: str | None = None


@dataclass(frozen=True, slots=True)
class OnlinePrompt:
    """
    Codex-facing handoff for an ambiguous match.

    The PromptHandler receives one of these and returns a PromptResponse.
    """

    path: Path | None
    source: str
    profile_summary: dict[str, Any]
    candidates: tuple[Candidate, ...]
    mode: MatchMode
    unattended: bool


@dataclass(frozen=True, slots=True)
class PromptResponse:
    """
    Codex-side decision for one prompt.

    ``action`` mirrors :data:`SelectorAction`; ``payload`` is the index for
    ``choose``, the ``"<source>:<id>"`` string for ``manual``, the new
    :class:`MatchMode` value (e.g. ``"auto"``) for ``set_policy``, or
    ``None`` otherwise.
    """

    action: Literal["choose", "skip", "manual", "abort", "set_unattended", "set_policy"]
    payload: int | str | None = None


@runtime_checkable
class PromptHandler(Protocol):
    """
    Codex's prompt-resolution callback.

    All handlers must implement :meth:`request` — the single-prompt form
    used by every code path that ships today. Handlers MAY additionally
    implement :meth:`request_many` to opt into the future batched-prompt
    delivery path (plan §3.4 / step 8): when the engine grows the
    per-session prompt queue, handlers exposing ``request_many`` get the
    full pending window in one call; handlers without it continue to be
    invoked one prompt at a time. v1 delivers prompts via ``request``
    only — see ``tasks/prepare-for-codex-writing/03-batched-prompt-handler.md``
    for the deferred implementation plan.
    """

    def request(self, prompt: OnlinePrompt) -> PromptResponse:
        """Resolve one prompt; return the chosen action/payload."""
        ...

    # request_many is intentionally omitted from this Protocol body so
    # existing single-method handlers structurally match. Handlers that
    # implement it should match BatchedPromptHandler below; callers detect
    # support via isinstance() / hasattr("request_many").


@runtime_checkable
class BatchedPromptHandler(Protocol):
    """
    A PromptHandler that also supports batched prompt delivery.

    Codex's defer-mode UI naturally yields a window of prompts the user
    will resolve together. When the engine gains the per-session prompt
    queue (step 8), it will prefer ``request_many`` over ``request`` for
    handlers structurally matching this Protocol.
    """

    def request(self, prompt: OnlinePrompt) -> PromptResponse:
        """Resolve one prompt; return the chosen action/payload."""
        ...

    def request_many(self, prompts: Sequence[OnlinePrompt]) -> Sequence[PromptResponse]:
        """
        Resolve a window of prompts in one call.

        Return one response per prompt, in the same order. The engine
        will not interleave ``request`` and ``request_many`` calls
        within a single drain window.
        """
        ...


@dataclass(frozen=True, slots=True)
class OnlineResult:
    """
    Per-file outcome of an online tagging call.

    ``tags`` is the file's full merged metadata, present even when the
    lookup applied nothing new (skip, no-match, deferred prompt) — it is
    NOT evidence of a match. ``matched`` is: it's True only when an
    online source actually contributed metadata, so writers should gate
    on it to avoid re-writing a file with its own existing tags.
    """

    path: Path
    tags: dict[str, Any] | None = None
    error: BaseException | None = None
    cancelled: bool = False
    matched: bool = False


@dataclass(frozen=True, slots=True)
class _CachedResolution:
    """A previously-handled prompt's outcome, keyed by series-level fingerprint."""

    action: SelectorAction
    payload: int | str | None
    # For "choose", the candidate's volume_id at the time the user picked.
    # We re-map to whichever candidate in the new prompt shares it.
    chosen_volume_id: int | None = None


@dataclass(frozen=True, slots=True)
class DeferredPrompt:
    """
    A prompt the session skipped under defer_prompts mode.

    Captures everything Codex needs to render the prompt later in a
    review-tagging UI: the file it came from, source, candidates, mode
    context, and the fingerprint used to key the dedup cache. Codex
    feeds the user's resolution back via :meth:`OnlineSession.preload_resolution`.
    """

    path: Path | None
    source: str
    fingerprint: str
    profile_summary: dict[str, Any]
    candidates: tuple[Candidate, ...]
    mode: MatchMode
    unattended: bool


# --- session ---------------------------------------------------------------


class OnlineSession:
    """
    Codex-friendly façade over Comicbox's online lookup.

    Construction validates per-source credentials and pre-computes the
    ComicboxSettings layer that each per-file Comicbox instance will see.
    Mutable state — ``mode``, ``unattended``, the cancel token — lives on
    the instance and may be updated from any thread.
    """

    def __init__(  # noqa: PLR0913
        self,
        *,
        sources: Iterable[str] = ("metron", "comicvine"),
        credentials: OnlineCredentials | None = None,
        mode: MatchMode = MatchMode.AUTO,
        unattended: bool = False,
        prompt_handler: PromptHandler | None = None,
        on_event: EventHandler | None = None,
        rematch: bool = False,
        first_wins: bool = True,
        defer_prompts: bool = False,
        series_batching: bool = True,
    ) -> None:
        """Validate inputs, build per-session state. See class docstring."""
        # Order is run priority: the first source runs first and, under
        # first_wins, its match ends the lookup for that comic.
        self._sources = tuple(dict.fromkeys(sources))
        self._validate_sources(self._sources)
        self._credentials = credentials or OnlineCredentials()
        self._validate_credentials(self._sources, self._credentials)
        self._mode: MatchMode = self._validate_mode(mode)
        self._unattended = unattended
        self._prompt_handler = prompt_handler
        self._on_event = on_event
        self._rematch = rematch
        self._first_wins = first_wins
        self._defer_prompts = defer_prompts
        self._series_batching = series_batching
        # Read config files / env exactly once per session. _build_config
        # used to call get_config() per file — wasted disk I/O on big
        # batches plus a behavioral surprise where a config-file edit
        # mid-batch changed settings for the remaining files.
        self._base_settings: ComicboxSettings = get_config()

        # Cancel token. Set when cancel() is called; checked between files
        # in tag_many() and consulted by the wired retry sleep
        # (_retry_sleep_wait), which aborts an in-flight rate-limit wait.
        self._cancel = threading.Event()
        self._state_lock = threading.Lock()

        # Prompt-dedup cache. Keyed by fingerprint of (source, normalized
        # series, sorted distinct candidate volume_ids); the stored entry
        # is the PromptResponse the handler returned the first time we
        # saw this fingerprint. For "choose" actions we also record the
        # candidate's volume_id so we can re-map the index when the new
        # prompt's candidate list is ordered differently. Per-session,
        # in-memory only — disk persistence is out of scope for v1.
        self._prompt_cache: dict[str, _CachedResolution] = {}
        self._prompt_cache_lock = threading.Lock()

        # Defer-prompts queue. When ``defer_prompts`` is set, the bridged
        # selector skips the user's PromptHandler and queues the prompt
        # here instead. Codex's flow: drain via ``deferred_prompts()`` at
        # end-of-batch, present in a review UI, then seed resolutions
        # back via ``preload_resolution()`` and re-run the affected files.
        self._deferred: list[DeferredPrompt] = []
        self._deferred_lock = threading.Lock()

        # Series cache (plan §3.10). Keyed by (source_name, series
        # fingerprint); value is the resolved volume_id. Populated by
        # the matcher on each cold-path acceptance; consulted before the
        # cold-path search on subsequent comics of the same series so
        # we make one `search` call per series instead of one per issue.
        # In-memory only per the resolved open question.
        self._series_cache: dict[tuple[str, str], int] = {}
        self._series_cache_lock = threading.Lock()

    # -- mutable session state ----------------------------------------------

    @property
    def mode(self) -> MatchMode:
        """Current session mode (read-only; mutate via set_mode())."""
        with self._state_lock:
            return self._mode

    @property
    def unattended(self) -> bool:
        """Current session unattended flag."""
        with self._state_lock:
            return self._unattended

    def set_mode(self, mode: MatchMode) -> None:
        """Change the session mode for subsequent file lookups."""
        validated = self._validate_mode(mode)
        with self._state_lock:
            self._mode = validated

    def set_unattended(self, *, unattended: bool) -> None:
        """Toggle the unattended flag for subsequent file lookups."""
        with self._state_lock:
            self._unattended = unattended

    def cancel(self) -> None:
        """Stop accepting new files. In-flight lookup runs to completion."""
        self._cancel.set()

    @property
    def cancelled(self) -> bool:
        """Whether cancel() has been called."""
        return self._cancel.is_set()

    # -- deferred prompts ---------------------------------------------------

    @property
    def defer_prompts(self) -> bool:
        """Whether the session is currently in defer-prompts mode."""
        return self._defer_prompts

    def set_defer_prompts(self, *, defer: bool) -> None:
        """Toggle defer-prompts mode for subsequent file lookups."""
        self._defer_prompts = defer

    def deferred_prompts(self) -> tuple[DeferredPrompt, ...]:
        """Snapshot the queued deferred prompts. Codex's review-UI input."""
        with self._deferred_lock:
            return tuple(self._deferred)

    def clear_deferred_prompts(self) -> None:
        """Drop the deferred-prompt queue without resolving them."""
        with self._deferred_lock:
            self._deferred.clear()

    def preload_resolution(
        self,
        fingerprint: str,
        *,
        action: Literal["choose", "skip", "manual"],
        payload: int | str | None = None,
        chosen_volume_id: int | None = None,
    ) -> None:
        """
        Seed the dedup cache so a re-run auto-resolves the fingerprint.

        Codex's defer-mode flow: drain ``deferred_prompts()`` after the
        batch, present them in a review UI, call ``preload_resolution()``
        for each one the user resolves, then re-tag the affected files
        (with defer_prompts toggled off if desired). The cache hit fires
        the same way it does for in-batch dedup. A non-empty cache is
        enough to install the bridged selector — but with no handler and
        defer_prompts off, a cache MISS (the re-search produced a
        different candidate set than the fingerprint was minted from)
        raises rather than falling back to the interactive CLI prompt.
        """
        entry = _CachedResolution(
            action=action, payload=payload, chosen_volume_id=chosen_volume_id
        )
        with self._prompt_cache_lock:
            self._prompt_cache[fingerprint] = entry

    # -- series-first batching ----------------------------------------------

    def preload_series_resolution(
        self, *, source: str, series_fingerprint: str, volume_id: int
    ) -> None:
        """
        Seed the series cache from outside the session.

        Codex's use case: persist resolved series across runs in its own
        DB and replay them at session-start to skip the cold-path search
        entirely on subsequent imports. Same first-writer-wins semantic
        as in-batch population — if the key is already cached, this is
        a no-op.
        """
        key = (source, series_fingerprint)
        with self._series_cache_lock:
            self._series_cache.setdefault(key, volume_id)

    def series_cache_snapshot(self) -> dict[tuple[str, str], int]:
        """Snapshot the current series cache. Useful for persistence."""
        with self._series_cache_lock:
            return dict(self._series_cache)

    def clear_series_cache(self) -> None:
        """Drop every series resolution from the cache."""
        with self._series_cache_lock:
            self._series_cache.clear()

    # -- rate limits --------------------------------------------------------

    def rate_limit_status(self) -> dict[str, dict[str, Any]]:
        """
        Snapshot of each enabled source's current rate-limit budget.

        v1 stub: returns an empty dict for every enabled source. The
        wiring to mokkari / simyan rate-limit buckets lands in the
        follow-up commit (see plan §3.5 / §3.6).
        """
        return {name: {} for name in self._sources}

    # -- per-file tagging ---------------------------------------------------

    def tag(self, path: Path) -> OnlineResult:
        """Tag one file synchronously."""
        if self._cancel.is_set():
            return OnlineResult(path=path, cancelled=True)
        try:
            tags, matched = self._run_one(path)
        except OnlineLookupAbortedError:
            # The handler answered "abort" (or a cancelled retry sleep
            # aborted an in-flight lookup). Abort means "abort the entire
            # run", not "skip this file": trip the cancel token so
            # tag_many drains the remaining paths as cancelled.
            self.cancel()
            return OnlineResult(path=path, cancelled=True)
        except Exception as exc:
            if self._on_event is not None:
                self._on_event(FileError(path=path, error=str(exc)))
            return OnlineResult(path=path, error=exc)
        return OnlineResult(path=path, tags=tags, matched=matched)

    def tag_many(self, paths: Iterable[Path]) -> Iterator[OnlineResult]:
        """
        Tag many files sequentially. Stops accepting new ones on cancel.

        When ``series_batching`` is on (the default), paths are reordered
        upfront by a lightweight filename-derived fingerprint so issues of
        the same series cluster together. The first issue of each cluster
        triggers the cold-path search; the remaining issues hit the
        per-session series cache and skip the search entirely. Order
        within a cluster is the original input order; the cluster order
        itself is deterministic (sorted by fingerprint) so re-runs
        produce identical cache-key sequences.
        """
        path_list = list(paths)
        if self._series_batching:
            path_list = sorted(path_list, key=_filename_series_fingerprint)
        for path in path_list:
            if self._cancel.is_set():
                yield OnlineResult(path=path, cancelled=True)
                continue
            yield self.tag(path)

    # -- internals ----------------------------------------------------------

    def _run_one(self, path: Path) -> tuple[dict[str, Any], bool]:
        config = self._build_config()
        with Comicbox(path, config=config) as cb:
            # Bridge the selector when we have a handler, when defer
            # mode is on (defer produces no handler call but still needs
            # to intercept the prompt to queue it), or when resolutions
            # were preloaded — the bridged selector is the only consumer
            # of the prompt cache, so a preload-only replay session
            # (codex's resolve flow) needs it too.
            if (
                self._prompt_handler is not None
                or self._defer_prompts
                or self._prompt_cache
            ):
                cb.set_online_selector(self._bridged_selector())
            if self._on_event is not None:
                cb.set_event_handler(self._on_event)
            if self._series_batching:
                cb.set_series_cache(self._series_cache)
            cb.set_retry_sleep(self._retry_sleep_wait)
            matched = cb.run_online_lookup()
            payload = cb.to_dict()
        return payload.get("comicbox", {}), matched

    def _retry_sleep_wait(self, delay: float) -> None:
        """
        Wait out a retry delay, aborting the lookup when cancel() fires.

        Rate-limit recovery can sleep for minutes per attempt; plain
        time.sleep would make cancel() wait out the whole budget while
        the archive sits open. Waiting on the cancel event instead lets
        a caller interrupt mid-retry; raising aborts the in-flight
        lookup, which tag() converts into a cancelled result.
        """
        if self._cancel.wait(delay):
            reason = "online: session cancelled during retry wait"
            raise OnlineLookupAbortedError(reason)

    def _bridged_selector(self) -> Callable[..., SelectorResult]:
        """
        Adapt the user's PromptHandler into a SelectorCallback.

        Translates Comicbox's positional SelectorCallback signature into
        a Codex-facing OnlinePrompt / PromptResponse pair. Consults the
        per-session prompt cache first; on cache miss falls through to
        defer-mode (queue + skip) or the user's handler, depending on
        ``defer_prompts``. Resolutions from the handler path are stored
        for next time.
        """
        handler = self._prompt_handler

        def _selector(
            profile: ComicProfile,
            candidates: Sequence[Candidate],
            ctx: SelectorContext,
        ) -> SelectorResult:
            cand_tuple = tuple(candidates)
            fingerprint = _prompt_fingerprint(ctx.source, profile, cand_tuple)
            cached = self._lookup_cached_prompt(fingerprint, cand_tuple)
            if cached is not None:
                if self._on_event is not None:
                    self._on_event(
                        PromptResolvedFromCache(
                            path=ctx.file_path,
                            source=ctx.source,
                            prompt_id=f"{ctx.source}:{id(candidates)}",
                            action=cached[0],
                            fingerprint=fingerprint,
                        )
                    )
                return cached
            if self._defer_prompts:
                self._defer_prompt(fingerprint, profile, cand_tuple, ctx)
                return ("skip", None)
            if handler is None:
                msg = (
                    "OnlineSession has no PromptHandler and defer_prompts "
                    "is disabled; cannot resolve an ambiguous match"
                )
                raise RuntimeError(msg)
            prompt = OnlinePrompt(
                path=ctx.file_path,
                source=ctx.source,
                profile_summary=_summarise_profile(profile),
                candidates=cand_tuple,
                mode=self.mode,
                unattended=self.unattended,
            )
            response = handler.request(prompt)
            self._sync_session_state(response)
            self._store_prompt_resolution(fingerprint, response, cand_tuple)
            return (response.action, response.payload)

        return _selector

    def _sync_session_state(self, response: PromptResponse) -> None:
        """
        Mirror session-level prompt actions into the session's own state.

        The box applies set_policy / set_unattended to its per-file config,
        but _run_one rebuilds that config for every file from the session's
        _mode / _unattended — without this sync the handler's decision
        would silently revert on the next file, despite PromptResponse
        documenting these as session-level actions.
        """
        if response.action == "set_unattended":
            self.set_unattended(unattended=True)
            return
        if response.action != "set_policy" or not isinstance(response.payload, str):
            return
        try:
            mode = MatchMode(response.payload)
        except ValueError:
            # Malformed payload: the box logs and declines it; nothing to sync.
            return
        if mode is MatchMode.ASK:
            # The session's set_mode rejects ASK (no built-in CLI prompt);
            # the box still honors it for the in-flight file.
            return
        self.set_mode(mode)

    def _defer_prompt(
        self,
        fingerprint: str,
        profile: ComicProfile,
        candidates: tuple[Candidate, ...],
        ctx: SelectorContext,
    ) -> None:
        """Queue this prompt for later resolution and emit PromptDeferred."""
        deferred = DeferredPrompt(
            path=ctx.file_path,
            source=ctx.source,
            fingerprint=fingerprint,
            profile_summary=_summarise_profile(profile),
            candidates=candidates,
            mode=self.mode,
            unattended=self.unattended,
        )
        with self._deferred_lock:
            self._deferred.append(deferred)
        if self._on_event is not None:
            self._on_event(
                PromptDeferred(
                    path=ctx.file_path,
                    source=ctx.source,
                    prompt_id=f"{ctx.source}:{id(candidates)}",
                    fingerprint=fingerprint,
                    n_candidates=len(candidates),
                )
            )

    def _lookup_cached_prompt(
        self, fingerprint: str, candidates: tuple[Candidate, ...]
    ) -> SelectorResult | None:
        """
        Match a fingerprint against the cache and re-map the choice.

        For ``choose`` we re-map the cached volume_id to whichever candidate
        in this prompt's list shares it. If no candidate matches, treat
        this as a cache miss and let the handler fire fresh.
        """
        with self._prompt_cache_lock:
            cached = self._prompt_cache.get(fingerprint)
        if cached is None:
            return None
        if cached.action != "choose":
            return (cached.action, cached.payload)
        if cached.chosen_volume_id is None:
            return None
        for i, cand in enumerate(candidates):
            if cand.volume_id is not None and cand.volume_id == cached.chosen_volume_id:
                return ("choose", i)
        return None

    def _store_prompt_resolution(
        self,
        fingerprint: str,
        response: PromptResponse,
        candidates: tuple[Candidate, ...],
    ) -> None:
        """Cache a fresh resolution under its fingerprint."""
        chosen_volume_id: int | None = None
        if response.action == "choose" and isinstance(response.payload, int):
            try:
                chosen_volume_id = candidates[response.payload].volume_id
            except IndexError:
                chosen_volume_id = None
        # Session-level actions (set_unattended / set_policy / abort) are
        # not cached: they apply once, not as a deferred decision.
        if response.action in {"set_unattended", "set_policy", "abort"}:
            return
        entry = _CachedResolution(
            action=response.action,
            payload=response.payload,
            chosen_volume_id=chosen_volume_id,
        )
        with self._prompt_cache_lock:
            self._prompt_cache[fingerprint] = entry

    def _build_config(self) -> ComicboxSettings:
        """
        Build a ComicboxSettings from the session's tagging preferences.

        Layered as ``replace`` over the default settings rather than fed
        through ``get_config(Namespace(...))`` because some of the fields
        we need to set (``enabled``, ``sources``, per-source auth dict)
        live on runtime-only fields the CLI namespace parser ignores.

        ``mode`` / ``unattended`` are session-mutable, so the cheap
        ``replace`` layering stays per-file; the disk read happened once
        in ``__init__``.
        """
        base = self._base_settings
        new_lookup = OnlineLookupSettings(
            enabled=True,
            sources=self._sources,
            match=self.mode,
            prompts=Prompts.NEVER if self.unattended else Prompts.ASK,
            rematch=self._rematch,
            first_wins=self._first_wins,
        )
        new_auth = OnlineAuthSettings(sources=self._build_auth_sources())
        new_online = replace(base.online, lookup=new_lookup, auth=new_auth)
        return replace(base, online=new_online)

    def _build_auth_sources(self) -> dict[str, OnlineSourceCredentials]:
        creds = self._credentials
        result: dict[str, OnlineSourceCredentials] = {}
        if "metron" in self._sources:
            result["metron"] = OnlineSourceCredentials(
                user=creds.metron_user,
                password=creds.metron_password,
                url=creds.metron_url,
            )
        if "comicvine" in self._sources:
            result["comicvine"] = OnlineSourceCredentials(
                key=creds.comicvine_key,
                url=creds.comicvine_url,
            )
        return result

    # -- validation ---------------------------------------------------------

    @staticmethod
    def _validate_sources(sources: tuple[str, ...]) -> None:
        if not sources:
            msg = "OnlineSession requires at least one source"
            raise OnlineConfigurationError(msg)
        unknown = set(sources) - _KNOWN_SOURCES
        if unknown:
            msg = (
                f"Unknown online sources: {sorted(unknown)}. "
                f"Expected a subset of {sorted(_KNOWN_SOURCES)}."
            )
            raise OnlineConfigurationError(msg)

    @staticmethod
    def _validate_credentials(
        sources: tuple[str, ...], creds: OnlineCredentials
    ) -> None:
        missing: list[str] = []
        if "metron" in sources and not (creds.metron_user and creds.metron_password):
            missing.append("metron requires metron_user and metron_password")
        if "comicvine" in sources and not creds.comicvine_key:
            missing.append("comicvine requires comicvine_key")
        if missing:
            msg = "Missing credentials for enabled online source(s):\n  " + "\n  ".join(
                missing
            )
            raise OnlineConfigurationError(msg)

    @staticmethod
    def _validate_mode(mode: MatchMode) -> MatchMode:
        if not isinstance(mode, MatchMode):  # pyright: ignore[reportUnnecessaryIsInstance]
            msg = f"OnlineSession.mode must be a MatchMode enum value; got {mode!r}."  # pyright: ignore[reportUnreachable]
            raise OnlineConfigurationError(msg)
        if mode is MatchMode.ASK:
            msg = (
                "OnlineSession.mode does not accept MatchMode.ASK; "
                "the session has no built-in prompt resolver. Use a "
                "PromptHandler or defer_prompts=True instead."
            )
            raise OnlineConfigurationError(msg)
        return mode


def _summarise_profile(profile: ComicProfile) -> dict[str, Any]:
    """Pluck the user-displayable fields off a ComicProfile."""
    fields_of_interest = (
        "series",
        "volume",
        "issue",
        "year",
        "publisher",
        "filename",
    )
    return {f: getattr(profile, f, None) for f in fields_of_interest}


def _prompt_fingerprint(
    source: str, profile: ComicProfile, candidates: tuple[Candidate, ...]
) -> str:
    """
    Build a deterministic key identifying the disambiguation question.

    Composed of the source name, series-level profile fields, and the sorted
    set of candidate volume_ids. Different issues of the same series collapse
    to the same fingerprint because their candidate volume_ids match —
    enabling the cache to auto-apply the user's prior series choice to
    every subsequent issue of that run.
    """
    series = (profile.series or "").strip().lower()
    publisher = (profile.publisher or "").strip().lower()
    volume_ids = tuple(
        sorted({c.volume_id for c in candidates if c.volume_id is not None})
    )
    # Fall back to candidate issue_ids when no volume_id is present (some
    # sources don't expose one) — this gives a strictly stricter
    # fingerprint, equivalent to "same exact candidate list."
    if not volume_ids:
        volume_ids = tuple(sorted(c.issue_id for c in candidates))
    parts = (source, series, str(profile.year or ""), publisher, repr(volume_ids))
    return "|".join(parts)


def _filename_series_fingerprint(path: Path) -> str:
    """
    Lightweight series fingerprint derived from the filename alone.

    Used by ``tag_many`` to group same-series comics together *before*
    opening any archives. Falls back to the bare filename when comicfn2dict
    can't extract a series — those files won't benefit from series
    batching but won't break it either (they sort to a deterministic
    "by filename" bucket).
    """
    from comicfn2dict import comicfn2dict

    try:
        parsed = comicfn2dict(path.name)
    except Exception:  # pragma: no cover — comicfn2dict is permissive
        return path.name.lower()
    series = str(parsed.get("series") or "").strip().lower()
    year = str(parsed.get("year") or "")
    return f"{series}|{year}" if series else f"~{path.name.lower()}"