<reasoning_content>
*   Goal: Refactor a job queue into three specific composable classes: `Job`, `RetryPolicy`, and `JobQueue`.
    *   Language: Python (standard library only).
    *   File: `solution.py`.

        *   `@dataclass`.
        *   Fields: `id: str`, `data: dict`, `priority: int = 0`, `retries: int = 0`.

        *   `__init__(self, max_attempts: int = 4)`.
        *   `run(self, fn: Callable, data) -> tuple[bool, int]`.
        *   Logic: Call `fn(data)`. If exception occurs, retry until `max_attempts` or success.
        *   Return: `(success, attempts_made)`.
        *   Constraint: Do *not* sleep.

        *   `add_job(self, job_id: str, data: dict, priority: int = 0) -> None`.
        *   `process_job(self, job_id: str, processor: Callable) -> bool`.
            *   Must use `RetryPolicy`.
        *   `get_next_job(self) -> tuple[str, dict] | None`.
            *   Return highest priority.
            *   FIFO within the same priority.

    *   `JobQueue` needs to store jobs.
    *   Priority Queue requirement: "highest-priority job... FIFO order within same priority".
    *   Standard library tool for priority queue: `heapq`.
    *   `heapq` is a min-heap. High priority usually means a larger number, but in many systems, "higher priority" means "lower number" (like 1 is higher than 10). However, the prompt says `priority: int = 0` and "highest-priority job". Usually, in programming tasks involving priorities, if not specified, I should assume higher number = higher priority OR lower number = higher priority.
    *   Wait, let's check standard `heapq` behavior. `heapq.heappush` puts the smallest element first.
    *   If `priority` is 0, 1, 2... and we want high priority to come first, and `priority=0` is the default, I need to decide if 0 is high or if higher numbers are high.
    *   The prompt says: `priority: int = 0`. Usually, if someone says "highest priority", they mean the largest number or the smallest number.
    *   Let's look at the requirement: "highest-priority job... FIFO order within the same priority".
    *   To implement FIFO within the same priority using `heapq`:
        *   Store tuple: `(-priority, insertion_counter, job)`.
        *   Negating priority makes it a max-heap for the integer value.
        *   `insertion_counter` handles the FIFO.
    *   Fields for `JobQueue`:
        *   `self._jobs = []` (the heap).
        *   `self._job_map = {}` (to look up job by `id` for `process_job`).
        *   `self._counter = 0` (for FIFO).
        *   `self._retry_policy = RetryPolicy()`.

    *   `add_job`:
        *   Create `Job`.
        *   Store in `_job_map`.
        *   Push `(-priority, self._counter, job)` to `_jobs`.
        *   Increment `self._counter`.

    *   `get_next_job`:
        *   `heappop` from `_jobs`.
        *   If heap empty, return `None`.
        *   Return `(job.id, job.data)`.
        *   Note: `get_next_job` should probably remove it from the queue? The prompt says "return...". Usually, getting a job in a queue implies consumption, but let's check. If I pop it, it's gone. If I just peek, it stays. "get_next_job" in queue terminology usually implies popping. Let's assume popping.

    *   `process_job`:
        *   Retrieve job from `_job_map` by `job_id`.
        *   If not found, return `False`.
        *   Use `self._retry_policy.run(processor, job.data)`.
        *   Return the `success` boolean.

    *   `RetryPolicy`:
        *   `attempts = 0`
        *   `while attempts < self.max_attempts:`
        *   `attempts += 1`
        *   `try: fn(data); return (True, attempts)`
        *   `except: pass`
        *   `return (False, attempts)`

    *   The prompt says: `get_next_job(self) -> tuple[str, dict] | None`.
    *   In `process_job`, I need to find the job by ID. This implies a dictionary is needed.
    *   If I `heappop` in `get_next_job`, the job is removed from the queue.
    *   What if `process_job` is called on a job that was already "gotten" via `get_next_job`?
    *   Standard queue pattern:
        1.  `add_job` puts it in the queue.
        2.  `get_next_job` takes it out of the queue (removes it).
        3.  `process_job` runs it.
    *   Wait, if `get_next_job` removes it from the heap, how does `process_job` find it if it uses `job_id`?
    *   Option A: `process_job` works on jobs currently in the queue.
    *   Option B: `get_next_job` just returns the data without removing it? No, that's a `peek`.
    *   Option C: The `JobQueue` maintains a registry of all jobs (even if not in the heap) or `process_job` is expected to be called *while* it's in the queue?
    *   Let's re-read: `process_job(self, job_id: str, processor: Callable) -> bool`. This takes an ID. This implies the queue knows about the job by ID.
    *   If `get_next_job` removes the job from the priority list, it's no longer "in the queue" in terms of scheduling, but the `JobQueue` object might still know about it to allow processing.
    *   However, usually, a job queue's `get_next` removes it so no one else gets it.
    *   Let's look at the sequence:
        1.  `add_job("1", {"x": 1}, priority=10)`
        2.  `job_id, data = get_next_job()` -> returns `("1", {"x": 1})`. The job is now "in flight".
        3.  `process_job("1", my_func)` -> executes and returns success.
    *   To support this, `JobQueue` should have:
        *   `self._pending_jobs = []` (the heap).
        *   `self._all_jobs = {}` (a dict of `job_id -> Job`).
    *   Wait, if `get_next_job` removes it from `_pending_jobs`, `process_job` still needs to find the `Job` object to get its `data` and `id`.
    *   So:
        *   `add_job`: Add to `_all_jobs` and `_pending_jobs`.
        *   `get_next_job`: Pop from `_pending_jobs`, return info. (Don't remove from `_all_jobs` yet?)
        *   Actually, if `get_next_job` is meant to be the way to consume, then the lifecycle is: Add -> Get -> Process.
        *   If I remove from `_pending_jobs` in `get_next_job`, I must keep it in `_all_jobs` until processed.

    *   Refined `JobQueue` state:
        *   `self._heap`: list of `(-priority, counter, job_id)`
        *   `self._jobs`: dict `job_id -> Job`
        *   `self._counter`: int

    *   `add_job(job_id, data, priority)`:
        *   `job = Job(id=job_id, data=data, priority=priority)`
        *   `self._jobs[job_id] = job`
        *   `heappush(self._heap, (-priority, self._counter, job_id))`
        *   `self._counter += 1`

    *   `get_next_job()`:
        *   If not `self._heap`, return `None`.
        *   `_, _, job_id = heappop(self._heap)`
        *   `job = self._jobs[job_id]`
        *   `return (job.id, job.data)`

    *   `process_job(job_id, processor)`:
        *   `job = self._jobs.get(job_id)`
        *   if not `job`: return `False`
        *   `success, attempts = self._retry_policy.run(processor, job.data)`
        *   If `success`: `del self._jobs[job_id]`
        *   Return `success`

    *   *Self-Correction on `process_job`*: Should `process_job` delete the job if it fails? The prompt doesn't say. It says `RetryPolicy` handles the retries internally. If `RetryPolicy.run` returns `False`, it means `max_attempts` were reached. I'll remove the job only on success to prevent memory leaks, or perhaps the user expects to handle failures. Given the context of a simple refactor, I'll remove on success. Actually, to be safe and professional, I'll remove it regardless of success/failure to ensure the `_jobs` dict doesn't grow forever, or just assume the user manages the lifecycle. But the prompt is specific about the interface. Let's stick to the most logical flow: once processed (successfully or not), it's done.

    *   *Wait*, `get_next_job` returns `(id, data)`. If it's popped from the heap, it's no longer in the queue for `get_next_job` to find it again. That's correct.

    *   `Priority`: `priority: int = 0`.
    *   "Highest-priority job": In many systems, 10 is higher than 1. In others, 0 is the highest. However, in Python's `heapq` (min-heap), a lower number comes out first. If I use `-priority`, then a priority of 10 becomes -10, and a priority of 1 becomes -1. -10 is smaller than -1, so -10 comes out first. This makes 10 the "highest priority". This matches the "higher number = higher priority" intuition.
    *   "FIFO order within the same priority": `self._counter` increments every time `add_job` is called. `( -priority, counter, job_id )`. If priorities are equal, the one with the smaller `counter` (the one added earlier) will be popped first. This is correct.

    *   `run(self, fn: Callable, data) -> tuple[bool, int]`
    *   `attempts_made`: If `max_attempts` is 4, and it fails 4 times, `attempts_made` is 4.
    *   The loop:
        ```python
        attempts = 0
        while attempts < self.max_attempts:
            attempts += 1
            try:
                fn(data)
                return True, attempts
            except Exception:
                continue
        return False, attempts
        ```

    *   `Job`: `dataclass`, `id`, `data`, `priority`, `retries`.
    *   `RetryPolicy`: `max_attempts`, `run`.
    *   `JobQueue`: `add_job`, `process_job`, `get_next_job`.
    *   Standard library only.
    *   `process_job` must use `RetryPolicy`.

    *   `job_id` in `add_job` and `Job` object.
    *   `data` in `Job` object.
    *   `process_job` takes `job_id` and `processor`.
    *   `get_next_job` returns `(id, data)`.

    One detail: `Job` has a `retries` field. My `RetryPolicy` returns how many attempts were made, but it doesn't explicitly update the `Job` object's `retries` field. Should it? The prompt says `Job` has `retries: int = 0`.
    If `process_job` is supposed to update the job, where does that happen?
    Actually, the `RetryPolicy` is a separate class. The `Job` is a dataclass.
    Let's look at `process_job` again: `process_job(self, job_id: str, processor: Callable) -> bool`.
    It doesn't say anything about updating the `Job` object's `retries` field, but the field exists.
    If `RetryPolicy.run` returns `(success, attempts_made)`, I could do `job.retries = attempts_made - 1` (or similar), but that's getting speculative. Since the prompt doesn't explicitly command updating the `retries` field, and `RetryPolicy` is encapsulated, I will focus on the core requirements. However, for completeness, if I were to use the `retries` field, it would be to track total failures across *multiple* `process_job` calls, but `RetryPolicy` handles retries *within* a single call.
    Wait, if `RetryPolicy` is for a *single* job execution attempt (with its internal retries), the `Job.retries` might be intended for something else, or maybe I should update it.
    Let's look at the requirements for `RetryPolicy.run`: "calls `fn(data)`, retries on any exception until success or `max_attempts` reached, returns `(success, attempts_made)`".
    This describes the *internal* retry logic.
    I'll implement exactly what's asked.

    *   `Job`: `id`, `data`, `priority`, `retries`.
    *   `RetryPolicy`: `max_attempts`, `run(fn, data)`.
    *   `JobQueue`: `add_job`, `process_job` (using `RetryPolicy`), `get_next_job` (highest priority, FIFO).

    One small thing: `process_job` needs to find the job. If I remove the job from the queue during `get_next_job`, I need to make sure `process_job` can still find it.
    If `get_next_job` returns the job info and removes it from the heap, the `Job` object must still exist in a dictionary so `process_job(job_id, ...)` can access it.

    ```python
    from dataclasses import dataclass
    from typing import Callable, Any
    import heapq

    @dataclass
    class Job:
        id: str
        data: dict
        priority: int = 0
        retries: int = 0

    class RetryPolicy:
        def __init__(self, max_attempts: int = 4):
            self.max_attempts = max_attempts

        def run(self, fn: Callable, data: Any) -> tuple[bool, int]:
            attempts = 0
            while attempts < self.max_attempts:
                attempts += 1
                try:
                    fn(data)
                    return True, attempts
                except Exception:
                    pass
            return False, attempts

    class JobQueue:
        def __init__(self):
            self._heap = []
            self._jobs = {}
            self._counter = 0
            self._retry_policy = RetryPolicy()

        def add_job(self, job_id: str, data: dict, priority: int = 0) -> None:
            job = Job(id=job_id, data=data, priority=priority)
            self._jobs[job_id] = job
            # heapq is a min-heap. To make it a max-priority queue:
            # Use -priority. To handle FIFO for same priority, use a counter.
            heapq.heappush(self._heap, (-priority, self._counter, job_id))
            self._counter += 1

        def process_job(self, job_id: str, processor: Callable) -> bool:
            job = self._jobs.get(job_id)
            if not job:
                return False
            
            success, attempts = self._retry_policy.run(processor, job.data)
            
            # Update job retries if necessary (optional, but good practice)
            # job.retries = attempts - 1 # If we assume retries means failed attempts
            
            if success:
                # Assuming once processed, it's removed from the system
                del self._jobs[job_id]
            return success

        def get_next_job(self) -> tuple[str, dict] | None:
            if not self._heap:
                return None
            
            # Peek at the highest priority item
            # But get_next usually implies popping.
            _, _, job_id = heapq.heappop(self._heap)
            job = self._jobs.get(job_id)
            
            if not job:
                # This might happen if a job was removed from _jobs but still in heap
                # (though in this logic that shouldn't happen unless process_job 
                # is called before get_next_job)
                return self.get_next_job()
                
            return (job.id, job.data)
    ```

    *Re-evaluating `process_job` and `get_next_job` interaction*:
    If `get_next_job` pops from the heap, the job is removed from the heap.
    If `process_job` is then called, it works on the `job_id` found in `self._jobs`.
    If `process_job` succeeds, it removes it from `self._jobs`.
    This works.

    Wait, what if `process_job` is called *before* `get_next_job`?
    The prompt says `get_next_job` returns the highest priority job. It doesn't say the job must be popped. But in a queue, `get` usually implies consumption.
    If I don't pop in `get_next_job`, the user would call `get_next_job` in a loop and get the same job forever.
    If I *do* pop in `get_next_job`, I am correctly implementing a queue.

    *One edge case*: If `process_job` is called on a job that was *already* popped by `get_next_job`, `self._jobs` still contains it, so it works.
    If `process_job` is called on a job that was *never* popped (it's still in the heap), it still works.

    One potential issue: If `process_job` fails and I *don't* delete it from `_jobs`, and then someone calls `get_next_job` again, they will get the job again? No, because `get_next_job` pops from the heap. If the job was already popped, it won't be in the heap anymore. So `get_next_job` won't return it again. This is correct behavior for a "once-delivered" queue.

    However, if `process_job` fails and I delete it from `_jobs`, it's gone.
    The prompt doesn't specify what happens to failed jobs. I'll assume they are removed from `_jobs` to prevent memory leaks.

    Wait, if `process_job` removes the job from `_jobs` on success, but `get_next_job` already removed it from `_heap`... that's fine.
    The only risk is if `process_job` is called on a job that was never `get_next_job`'d.
    If `process_job` is called, it finds the job in `_jobs`. If successful, it deletes it from `_jobs`.
    If `get_next_job` is then called, it pops from `_heap`. If that `job_id` is no longer in `_jobs` (because `process_job` finished it), `get_next_job` should ignore it and pop the next one.

    Let's refine `get_next_job` to handle the case where a job was processed and removed from `_jobs` while still being in `_heap`.

    ```python
    def get_next_job(self) -> tuple[str, dict] | None:
        while self._heap:
            _, _, job_id = heapq.heappop(self._heap)
            job = self._jobs.get(job_id)
            if job:
                return (job.id, job.data)
        return None
    ```

    This is robust.

    *   `Job` is dataclass? Yes.
    *   `RetryPolicy` has `max_attempts` and `run`? Yes.
    *   `RetryPolicy.run` returns `(success, attempts_made)` and no sleep? Yes.
    *   `JobQueue` has `add_job`, `process_job` (uses `RetryPolicy`), `get_next_job`? Yes.
    *   `get_next_job` returns highest priority and FIFO? Yes (via `-priority` and `counter`).
    *   Only standard library? Yes.

    *One detail*: `priority: int = 0` is the default. If I use `-priority` in the heap, a job with priority 0 goes to `-0`. A job with priority 10 goes to `-10`. Since -10 < 0, the priority 10 job comes out first. This is correct.
</reasoning_content>

<content>
```python
from dataclasses import dataclass
from typing import Callable, Any
import heapq

@dataclass
class Job:
    id: str
    data: dict
    priority: int = 0
    retries: int = 0

class RetryPolicy:
    def __init__(self, max_attempts: int = 4):
        self.max_attempts = max_attempts

    def run(self, fn: Callable, data: Any) -> tuple[bool, int]:
        attempts = 0
        while attempts < self.max_attempts:
            attempts += 1
            try:
                fn(data)
                return True, attempts
            except Exception:
                continue
        return False, attempts

class JobQueue:
    def __init__(self):
        self._heap = []
        self._jobs = {}
        self._counter = 0
        self._retry_policy = RetryPolicy()

    def add_job(self, job_id: str, data: dict, priority: int = 0) -> None:
        job = Job(id=job_id, data=data, priority=priority)
        self._jobs[job_id] = job
        # Use -priority for max-heap behavior via heapq (min-heap)
        # Use self._counter to ensure FIFO for identical priorities
        heapq.heappush(self._heap, (-priority, self._counter, job_id))
        self._counter += 1

    def process_job(self, job_id: str, processor: Callable) -> bool:
        job = self._jobs.get(job_id)
        if not job:
            return False
        
        success, attempts = self._retry_policy.run(processor, job.data)
        
        if success:
            # Remove from registry once successfully processed
            del self._jobs[job_id]
            
        return success

    def get_next_job(self) -> tuple[str, dict] | None:
        while self._heap:
            _, _, job_id = heapq.heappop(self._heap)
            job = self._jobs.get(job_id)
            if job:
                return (job.id, job.data)
        return None

```
</content>