from __future__ import annotations

import json
import sys
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Mapping, Optional, Set

import requests

from autotouch_cli.exceptions import AutotouchAPIError

__all__ = [
    "JobPollAttempt",
    "ImportStatusAttempt",
    "poll_job_once",
    "poll_job_until_terminal",
    "poll_import_once",
    "poll_import_until_terminal",
]


@dataclass(frozen=True)
class JobPollAttempt:
    kind: str
    job_doc: Optional[Dict[str, Any]] = None
    error: Optional[str] = None
    status_code: Optional[int] = None
    body: Any = None


@dataclass(frozen=True)
class ImportStatusAttempt:
    kind: str
    status_body: Optional[Dict[str, Any]] = None
    status: Optional[str] = None
    signature: Optional[str] = None
    error: Optional[str] = None


def _decode_response_body(response: Any) -> Any:
    content_type = response.headers.get("content-type", "").lower()
    if "application/json" in content_type:
        try:
            return response.json()
        except Exception:
            return response.text
    return response.text


def poll_job_once(
    *,
    url: str,
    headers: Mapping[str, str],
    timeout_seconds: int,
    verbose: bool,
    requests_get: Callable[..., Any] = requests.get,
) -> JobPollAttempt:
    if verbose:
        print(f"[HTTP] GET {url}", file=sys.stderr)
    try:
        response = requests_get(
            url,
            headers=dict(headers),
            timeout=max(1, int(timeout_seconds or 30)),
        )
    except requests.RequestException as exc:
        return JobPollAttempt(kind="retryable_error", error=str(exc))

    if response.status_code >= 500:
        return JobPollAttempt(
            kind="retryable_error",
            error=f"server_error_{response.status_code}",
            status_code=response.status_code,
        )
    if response.status_code == 404:
        return JobPollAttempt(kind="not_found", status_code=404)

    parsed_body = _decode_response_body(response)
    if response.status_code >= 300:
        return JobPollAttempt(
            kind="fatal_http_error",
            status_code=response.status_code,
            body=parsed_body,
        )
    if not isinstance(parsed_body, dict):
        return JobPollAttempt(
            kind="fatal_response_error",
            error=f"unexpected bulk job response: {parsed_body}",
            body=parsed_body,
        )
    return JobPollAttempt(
        kind="job",
        job_doc=parsed_body,
        status_code=response.status_code,
    )


def poll_job_until_terminal(
    *,
    job_id: str,
    url: str,
    headers: Mapping[str, str],
    interval_seconds: int,
    wait_timeout_seconds: int,
    request_timeout_seconds: int,
    verbose: bool,
    compact: bool,
    once: bool,
    print_updates: bool,
    terminal_statuses: Set[str],
    job_snapshot_fn: Callable[[Dict[str, Any]], Dict[str, Any]],
    emit_progress_fn: Callable[[Any, bool], None],
    error_body_writer: Callable[[Any], None],
    requests_get: Callable[..., Any] = requests.get,
    monotonic_fn: Callable[[], float] = time.monotonic,
    sleep_fn: Callable[[float], None] = time.sleep,
) -> Dict[str, Any]:
    interval = max(1, int(interval_seconds or 2))
    max_wait = max(0, int(wait_timeout_seconds or 0))
    started = monotonic_fn()
    polls = 0
    heartbeat_every_polls = 5
    max_consecutive_poll_errors = 6
    max_not_found_grace_polls = max(3, int(20 // interval) + 1)
    consecutive_poll_errors = 0
    not_found_polls = 0
    last_status: Optional[str] = None
    last_job_doc: Optional[Dict[str, Any]] = None

    while True:
        polls += 1
        attempt = poll_job_once(
            url=url,
            headers=headers,
            timeout_seconds=request_timeout_seconds,
            verbose=verbose,
            requests_get=requests_get,
        )

        if attempt.kind == "retryable_error":
            consecutive_poll_errors += 1
            if print_updates and (consecutive_poll_errors == 1 or consecutive_poll_errors % 3 == 0):
                emit_progress_fn(
                    {
                        "event": "job.polling_error",
                        "job_id": job_id,
                        "status": "polling_error",
                        "polls": polls,
                        "consecutive_errors": consecutive_poll_errors,
                        "error": attempt.error,
                    },
                    compact,
                )
            if max_wait > 0 and (monotonic_fn() - started) >= max_wait:
                return {
                    "job": {"job_id": job_id, "status": "polling_error", "error": attempt.error},
                    "polls": polls,
                    "timed_out": True,
                }
            if consecutive_poll_errors >= max_consecutive_poll_errors:
                raise AutotouchAPIError(
                    f"polling job {job_id} failed repeatedly ({consecutive_poll_errors} consecutive errors)"
                )
            sleep_fn(interval)
            continue

        if attempt.kind == "fatal_http_error":
            error_body_writer(attempt.body)
            raise AutotouchAPIError(
                f"API {attempt.status_code}",
                status_code=attempt.status_code,
                response_body=str(attempt.body),
            )

        if attempt.kind == "fatal_response_error":
            raise AutotouchAPIError(str(attempt.error))

        consecutive_poll_errors = 0

        if attempt.kind == "not_found":
            not_found_polls += 1
            if not once and last_job_doc is None and not_found_polls <= max_not_found_grace_polls:
                job_doc: Dict[str, Any] = {
                    "job_id": job_id,
                    "status": "queued",
                    "provider": None,
                    "processed_rows": 0,
                    "total_rows": 0,
                    "error_rows": 0,
                    "credits_used": 0.0,
                    "billable": False,
                    "updated_at": None,
                    "not_found_polls": not_found_polls,
                }
            elif last_job_doc is not None:
                unknown_job = dict(last_job_doc)
                unknown_job["status"] = "unknown_not_found"
                unknown_job["not_found_polls"] = not_found_polls
                unknown_job["note"] = "job endpoint returned 404 before terminal status"
                return {"job": unknown_job, "polls": polls, "timed_out": False}
            else:
                missing_job = {
                    "job_id": job_id,
                    "status": "not_found",
                    "not_found_polls": not_found_polls,
                }
                return {"job": missing_job, "polls": polls, "timed_out": False}
        else:
            job_doc = dict(attempt.job_doc or {})
            last_job_doc = job_doc
            not_found_polls = 0

        status = str(job_doc.get("status") or "").lower()
        snapshot = job_snapshot_fn(job_doc)
        snapshot["event"] = "job.progress"
        snapshot["terminal"] = status in terminal_statuses
        snapshot["status_url"] = url
        snapshot["polls"] = polls
        snapshot["elapsed_seconds"] = int(max(0.0, monotonic_fn() - started))

        should_print = print_updates and (
            once
            or polls == 1
            or status != last_status
            or (polls % heartbeat_every_polls == 0)
        )
        if should_print:
            emit_progress_fn(snapshot, compact)
        last_status = status

        if once:
            return {"job": job_doc, "polls": polls, "timed_out": False}
        if status in terminal_statuses:
            return {"job": job_doc, "polls": polls, "timed_out": False}
        if max_wait > 0 and (monotonic_fn() - started) >= max_wait:
            return {"job": job_doc, "polls": polls, "timed_out": True}

        sleep_fn(interval)


def poll_import_once(
    *,
    table_id: str,
    task_id: str,
    base_url: str,
    token: str,
    use_x_api_key: bool,
    request_timeout_seconds: int,
    verbose: bool,
    request_api_fn: Callable[..., Any],
) -> ImportStatusAttempt:
    status_body = request_api_fn(
        "GET",
        f"/api/tables/{table_id}/import-status/{task_id}",
        base_url=base_url,
        token=token,
        use_x_api_key=use_x_api_key,
        timeout=request_timeout_seconds,
        verbose=verbose,
    )
    if not isinstance(status_body, dict):
        return ImportStatusAttempt(
            kind="fatal_response_error",
            error=f"unexpected import status response: {status_body}",
        )

    status = str(status_body.get("status") or "").lower()
    signature = json.dumps(
        {
            "status": status,
            "progress": status_body.get("progress"),
            "total": status_body.get("total"),
            "progress_percent": status_body.get("progress_percent"),
            "stage": status_body.get("stage"),
            "message": status_body.get("message"),
        },
        sort_keys=True,
        default=str,
    )
    return ImportStatusAttempt(
        kind="status",
        status_body=status_body,
        status=status,
        signature=signature,
    )


def poll_import_until_terminal(
    *,
    table_id: str,
    task_id: str,
    base_url: str,
    token: str,
    use_x_api_key: bool,
    interval_seconds: int,
    wait_timeout_seconds: int,
    request_timeout_seconds: int,
    verbose: bool,
    compact: bool,
    output_mode: str,
    emit_progress_fn: Callable[[Any, bool], None],
    request_api_fn: Callable[..., Any],
    monotonic_fn: Callable[[], float] = time.monotonic,
    sleep_fn: Callable[[float], None] = time.sleep,
) -> Dict[str, Any]:
    interval = max(1, int(interval_seconds or 2))
    max_wait = max(0, int(wait_timeout_seconds or 0))
    started = monotonic_fn()
    polls = 0
    last_signature: Optional[str] = None

    while True:
        polls += 1
        attempt = poll_import_once(
            table_id=table_id,
            task_id=task_id,
            base_url=base_url,
            token=token,
            use_x_api_key=use_x_api_key,
            request_timeout_seconds=request_timeout_seconds,
            verbose=verbose,
            request_api_fn=request_api_fn,
        )

        if attempt.kind == "fatal_response_error":
            raise AutotouchAPIError(str(attempt.error))

        if attempt.signature != last_signature and output_mode != "json":
            emit_progress_fn(attempt.status_body, compact)
            last_signature = attempt.signature

        if attempt.status in {"completed", "failed"}:
            return {"status": attempt.status_body, "polls": polls, "timed_out": False}
        if max_wait > 0 and (monotonic_fn() - started) >= max_wait:
            return {"status": attempt.status_body, "polls": polls, "timed_out": True}

        sleep_fn(interval)
