"""Bulk data processor — chunked processing for large datasets.

Provides memory-efficient batch inference with progress callbacks,
error isolation per-record, and result streaming as chunks complete.
Stdlib-only (ADR-021).
"""

from __future__ import annotations

import threading
import time
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field


@dataclass
class BulkProgress:
    """Progress tracker for bulk operations."""

    total: int = 0
    processed: int = 0
    succeeded: int = 0
    failed: int = 0
    started_at: float = field(default_factory=time.time)

    @property
    def elapsed_s(self) -> float:
        return time.time() - self.started_at

    @property
    def percent(self) -> float:
        if self.total <= 0:
            return 0.0
        return min(100.0, (self.processed / self.total) * 100)

    @property
    def rate(self) -> float:
        """Records per second."""
        elapsed = self.elapsed_s
        if elapsed <= 0:
            return 0.0
        return self.processed / elapsed

    @property
    def eta_s(self) -> float:
        """Estimated seconds remaining."""
        if self.rate <= 0:
            return 0.0
        remaining = self.total - self.processed
        return remaining / self.rate

    def to_dict(self) -> dict:
        return {
            "total": self.total,
            "processed": self.processed,
            "succeeded": self.succeeded,
            "failed": self.failed,
            "percent": round(self.percent, 1),
            "elapsed_s": round(self.elapsed_s, 2),
            "rate_per_sec": round(self.rate, 1),
            "eta_s": round(self.eta_s, 1),
        }


@dataclass
class BulkResult:
    """Result of a bulk processing operation."""

    results: list[dict] = field(default_factory=list)
    errors: list[dict] = field(default_factory=list)
    progress: BulkProgress = field(default_factory=BulkProgress)

    @property
    def success_rate(self) -> float:
        total = self.progress.processed
        if total == 0:
            return 0.0
        return self.progress.succeeded / total

    def to_dict(self) -> dict:
        return {
            "result_count": len(self.results),
            "error_count": len(self.errors),
            "success_rate": round(self.success_rate, 4),
            "progress": self.progress.to_dict(),
        }


ProgressCallback = Callable[[BulkProgress], None]
ChunkCallback = Callable[[list[dict]], None]


class BulkProcessor:
    """Memory-efficient bulk data processor with chunked execution.

    Processes records in configurable chunks, calling handlers per-record
    with error isolation (one bad record doesn't crash the batch).

    Usage::

        processor = BulkProcessor(chunk_size=100)
        result = processor.run(
            records=large_dataset,
            handler=my_inference_fn,
            on_progress=lambda p: print(f"{p.percent}%"),
        )
    """

    def __init__(
        self,
        chunk_size: int = 100,
        max_errors: int = 0,
    ) -> None:
        """Initialize processor.

        chunk_size: records per chunk (default 100).
        max_errors: stop after N errors (0 = unlimited).
        """
        self._chunk_size = max(1, chunk_size)
        self._max_errors = max_errors

    def run(
        self,
        records: list[dict] | Iterator[dict],
        handler: Callable[[dict], dict | None],
        on_progress: ProgressCallback | None = None,
        on_chunk: ChunkCallback | None = None,
    ) -> BulkResult:
        """Process all records synchronously.

        handler: called per-record, returns result dict or None to skip.
        on_progress: called after each record.
        on_chunk: called after each chunk with that chunk's results.
        """
        record_list = list(records)
        progress = BulkProgress(total=len(record_list))
        all_results: list[dict] = []
        all_errors: list[dict] = []
        chunk_results: list[dict] = []

        for i, record in enumerate(record_list):
            try:
                result = handler(record)
                if result is not None:
                    chunk_results.append(result)
                    all_results.append(result)
                progress.succeeded += 1
            except Exception as exc:
                all_errors.append({
                    "index": i,
                    "error": str(exc),
                    "record_id": record.get("id", i),
                })
                progress.failed += 1
                if (
                    self._max_errors > 0
                    and progress.failed >= self._max_errors
                ):
                    progress.processed = i + 1
                    if on_progress:
                        on_progress(progress)
                    break

            progress.processed = i + 1

            if on_progress:
                on_progress(progress)

            if len(chunk_results) >= self._chunk_size:
                if on_chunk:
                    on_chunk(chunk_results)
                chunk_results = []

        # Flush remaining chunk
        if chunk_results and on_chunk:
            on_chunk(chunk_results)

        return BulkResult(
            results=all_results,
            errors=all_errors,
            progress=progress,
        )

    def run_threaded(
        self,
        records: list[dict],
        handler: Callable[[dict], dict | None],
        n_workers: int = 2,
        on_progress: ProgressCallback | None = None,
    ) -> BulkResult:
        """Process records using multiple threads.

        Splits records into chunks and processes each chunk in a
        separate thread. Results are collected thread-safely.
        """
        progress = BulkProgress(total=len(records))
        all_results: list[dict] = []
        all_errors: list[dict] = []
        lock = threading.Lock()

        def process_chunk(chunk: list[dict], offset: int) -> None:
            for j, record in enumerate(chunk):
                try:
                    result = handler(record)
                    if result is not None:
                        with lock:
                            all_results.append(result)
                    with lock:
                        progress.succeeded += 1
                except Exception as exc:
                    with lock:
                        all_errors.append({
                            "index": offset + j,
                            "error": str(exc),
                            "record_id": record.get("id", offset + j),
                        })
                        progress.failed += 1

                with lock:
                    progress.processed += 1
                    if on_progress:
                        on_progress(progress)

        # Split into worker chunks
        chunk_size = max(1, len(records) // n_workers)
        chunks: list[tuple[list[dict], int]] = []
        for i in range(0, len(records), chunk_size):
            chunks.append((records[i:i + chunk_size], i))

        threads = [
            threading.Thread(
                target=process_chunk, args=(chunk, offset)
            )
            for chunk, offset in chunks
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        return BulkResult(
            results=all_results,
            errors=all_errors,
            progress=progress,
        )

    def iter_results(
        self,
        records: list[dict],
        handler: Callable[[dict], dict | None],
    ) -> Iterator[dict]:
        """Yield results one at a time as they complete.

        For streaming to clients without waiting for full completion.
        """
        for record in records:
            try:
                result = handler(record)
                if result is not None:
                    yield result
            except Exception:
                continue

    def dry_run(
        self,
        records: list[dict],
        handler: Callable[[dict], dict | None],
        sample_size: int = 10,
    ) -> dict:
        """Run handler on a sample to validate before full execution.

        Returns timing and error estimates.
        """
        sample = records[:sample_size]
        start = time.time()
        errors = 0
        successes = 0

        for record in sample:
            try:
                handler(record)
                successes += 1
            except Exception:
                errors += 1

        elapsed = time.time() - start
        per_record = elapsed / len(sample) if sample else 0.0

        return {
            "sample_size": len(sample),
            "total_records": len(records),
            "sample_successes": successes,
            "sample_errors": errors,
            "avg_time_per_record_ms": round(per_record * 1000, 2),
            "estimated_total_time_s": round(
                per_record * len(records), 2
            ),
            "estimated_error_rate": round(
                errors / len(sample), 4
            ) if sample else 0.0,
        }


__all__ = [
    "BulkProcessor",
    "BulkProgress",
    "BulkResult",
    "ChunkCallback",
    "ProgressCallback",
]
