"""Run-flow business logic extracted from cli.py.

Contains payload normalisation, column resolution, projection preflight
warnings, row selection, and the core run orchestrator.  Every function that
needs CLI-global state (request helpers, output helpers, constants) receives
it via explicit parameters so this module stays free of global side-effects.
"""

from __future__ import annotations

import argparse
import json
import sys
from typing import Any, Callable, Dict, List, Optional, Set


from autotouch_cli.exceptions import (
    AutotouchAPIError,
    AutotouchCreditError,
    AutotouchInputError,
    AutotouchNotFoundError,
    AutotouchTimeoutError,
)

__all__ = [
    "normalize_run_payload",
    "resolve_column_key",
    "extract_columns_list",
    "extract_jobs_list",
    "is_json_enrichment_column",
    "is_processed_cell_value",
    "print_projection_preflight_warnings",
    "build_projection_preflight_warnings",
    "select_next_row_ids",
    "execute_run_flow",
]


# ---------------------------------------------------------------------------
# Payload helpers
# ---------------------------------------------------------------------------


def normalize_run_payload(
    args: argparse.Namespace,
    *,
    load_json_input_fn: Callable[..., Any],
) -> Dict[str, Any]:
    """Assemble scope/filters/etc from argparse *args* into a run payload."""

    explicit_payload = load_json_input_fn(
        inline_json=getattr(args, "data_json", None),
        file_path=getattr(args, "data_file", None),
        context="data",
        default=None,
    )
    if explicit_payload is not None:
        if not isinstance(explicit_payload, dict):
            raise AutotouchInputError("run payload must be a JSON object")
        return explicit_payload

    scope = str(getattr(args, "scope", "all") or "all")
    payload: Dict[str, Any] = {"scope": scope}
    row_ids = [r.strip() for r in (getattr(args, "row_ids", None) or []) if r and str(r).strip()]

    if scope == "row":
        if getattr(args, "row_id", None):
            payload["rowIds"] = [str(args.row_id)]
        elif row_ids:
            payload["rowIds"] = [str(row_ids[0])]
        else:
            raise AutotouchInputError("--row-id or --row-ids is required for scope=row")

    if scope == "subset":
        if not row_ids:
            raise AutotouchInputError("--row-ids required for scope=subset")
        payload["rowIds"] = row_ids

    if scope == "firstN":
        first_n = getattr(args, "first_n", None)
        if not first_n or int(first_n) <= 0:
            raise AutotouchInputError("--first-n must be > 0 for scope=firstN")
        payload["firstN"] = int(first_n)

    filters = load_json_input_fn(
        inline_json=getattr(args, "filters_json", None),
        file_path=getattr(args, "filters_file", None),
        context="filters",
        default=None,
    )
    if filters is not None:
        payload["filters"] = filters

    payload["unprocessedOnly"] = bool(getattr(args, "unprocessed_only", True))

    return payload


# ---------------------------------------------------------------------------
# Column / job resolution helpers
# ---------------------------------------------------------------------------


def resolve_column_key(
    *,
    table_id: str,
    column_id: str,
    base_url: str,
    token: str,
    use_x_api_key: bool,
    timeout: int,
    verbose: bool,
    request_api_fn: Callable[..., Any],
) -> str:
    """Fetch column list from the API and resolve *column_id* to its key."""

    columns_raw = request_api_fn(
        "GET",
        f"/api/tables/{table_id}/columns",
        base_url=base_url,
        token=token,
        use_x_api_key=use_x_api_key,
        timeout=timeout,
        verbose=verbose,
    )
    if isinstance(columns_raw, list):
        columns = columns_raw
    elif isinstance(columns_raw, dict):
        columns = columns_raw.get("columns") or columns_raw.get("items") or columns_raw.get("data") or []
    else:
        columns = []

    target = str(column_id)
    for col in columns:
        if not isinstance(col, dict):
            continue
        cid = str(col.get("id") or col.get("_id") or "")
        if cid != target:
            continue
        key = str(col.get("key") or "").strip()
        if key:
            return key
        break

    raise AutotouchAPIError(f"failed to resolve column key for column_id={column_id}")


def extract_columns_list(columns_raw: Any) -> List[Dict[str, Any]]:
    """Extract a columns array from an API response (list or envelope)."""

    if isinstance(columns_raw, list):
        return [col for col in columns_raw if isinstance(col, dict)]
    if isinstance(columns_raw, dict):
        candidates = columns_raw.get("columns") or columns_raw.get("items") or columns_raw.get("data") or []
        if isinstance(candidates, list):
            return [col for col in candidates if isinstance(col, dict)]
    return []


def extract_jobs_list(jobs_raw: Any) -> List[Dict[str, Any]]:
    """Extract a jobs array from an API response (list or envelope)."""

    if isinstance(jobs_raw, list):
        return [job for job in jobs_raw if isinstance(job, dict)]
    if isinstance(jobs_raw, dict):
        candidates = jobs_raw.get("jobs") or jobs_raw.get("items") or jobs_raw.get("data") or []
        if isinstance(candidates, list):
            return [job for job in candidates if isinstance(job, dict)]
    return []


def is_json_enrichment_column(column: Dict[str, Any]) -> bool:
    """Return True if *column* is an enrichment column with dataType=json."""

    kind = str(column.get("kind") or "").strip().lower()
    data_type = str(column.get("dataType") or column.get("data_type") or "").strip().lower()
    return kind == "enrichment" and data_type == "json"


def is_processed_cell_value(value: Any) -> bool:
    """Return True when *value* indicates the cell has been processed."""

    if value is None:
        return False
    if isinstance(value, str):
        return value.strip() != ""
    if isinstance(value, (list, tuple, set, dict)):
        return len(value) > 0
    return True


def _int_or_zero(value: Any) -> int:
    try:
        return int(value or 0)
    except Exception:
        return 0


def _post_run_terminal_summary(job: Dict[str, Any]) -> Dict[str, Any]:
    total_rows = _int_or_zero(job.get("total_rows"))
    processed_rows = _int_or_zero(job.get("processed_rows"))
    error_rows = _int_or_zero(job.get("error_rows"))
    skipped_rows = _int_or_zero(job.get("skipped_rows"))
    accounted_rows = processed_rows + error_rows + skipped_rows
    return {
        "total_rows": total_rows,
        "processed_rows": processed_rows,
        "error_rows": error_rows,
        "skipped_rows": skipped_rows,
        "terminal_remaining_rows": max(total_rows - accounted_rows, 0),
        "pending_batches": _int_or_zero(job.get("pending_batches")),
        "terminal_reason": job.get("terminal_reason"),
    }


def _post_run_reconciliation_summary(
    *,
    args: argparse.Namespace,
    payload: Dict[str, Any],
    job: Dict[str, Any],
    token: str,
    request_api_fn: Callable[..., Any],
) -> Dict[str, Any]:
    summary = _post_run_terminal_summary(job)
    scope = str(payload.get("scope") or getattr(args, "scope", "") or "").strip().lower()
    summary["scope"] = scope or None
    filters = payload.get("filters")

    if scope not in {"all", "filtered"}:
        return summary

    params: Optional[Dict[str, Any]] = None
    if scope == "filtered" and isinstance(filters, dict):
        params = {"filters": json.dumps(filters)}

    try:
        response = request_api_fn(
            "GET",
            f"/api/tables/{args.table_id}/columns/{args.column_id}/unprocessed-count",
            base_url=args.base_url,
            token=token,
            use_x_api_key=args.use_x_api_key,
            params=params,
            timeout=args.timeout,
            verbose=args.verbose,
        )
        if isinstance(response, dict):
            summary["column_unprocessed_rows_remaining"] = _int_or_zero(
                response.get("unprocessedCount", response.get("unprocessed_count"))
            )
            summary["column_total_rows"] = _int_or_zero(response.get("totalCount", response.get("total_count")))
            summary["column_processed_rows"] = _int_or_zero(
                response.get("processedCount", response.get("processed_count"))
            )
    except SystemExit as exc:
        summary["reconciliation_warning"] = f"unprocessed-count lookup failed with exit code {exc.code}"
    except Exception as exc:
        summary["reconciliation_warning"] = str(exc)

    return summary


# ---------------------------------------------------------------------------
# Projection preflight warnings
# ---------------------------------------------------------------------------


def _projection_recommended_run_command(table_id: str, column_id: str) -> str:
    return (
        f"autotouch columns run --table-id {table_id} --column-id {column_id} "
        "--scope all --show-estimate --wait --output json"
    )


def _projection_preflight_warn(
    *,
    code: str,
    column: Dict[str, Any],
    reason: str,
    recommended_next_command: str,
    latest_job_status: Optional[str] = None,
    column_status: Optional[str] = None,
    sample_rows_scanned: Optional[int] = None,
    sample_non_empty_values: Optional[int] = None,
) -> Dict[str, Any]:
    warning: Dict[str, Any] = {
        "code": code,
        "severity": "warning",
        "source_column_id": str(column.get("id") or column.get("_id") or ""),
        "source_column_key": str(column.get("key") or ""),
        "source_column_label": str(column.get("label") or column.get("key") or ""),
        "reason": reason,
        "recommended_next_command": recommended_next_command,
    }
    if latest_job_status:
        warning["latest_job_status"] = latest_job_status
    if column_status:
        warning["column_status"] = column_status
    if sample_rows_scanned is not None:
        warning["sample_rows_scanned"] = int(sample_rows_scanned)
    if sample_non_empty_values is not None:
        warning["sample_non_empty_values"] = int(sample_non_empty_values)
    return warning


def print_projection_preflight_warnings(warnings: List[Dict[str, Any]]) -> None:
    """Emit human-readable warnings to stderr for projection preflight issues."""

    if not warnings:
        return
    print(
        f"WARNING: projection preflight found {len(warnings)} source column(s) that may not be ready yet.",
        file=sys.stderr,
    )
    for warning in warnings:
        col_label = warning.get("source_column_label") or warning.get("source_column_key") or warning.get("source_column_id")
        col_id = warning.get("source_column_id") or "unknown"
        reason = warning.get("reason") or "source may be unprocessed"
        print(f"WARNING: [{warning.get('code')}] {col_label} ({col_id}): {reason}", file=sys.stderr)
        cmd = warning.get("recommended_next_command")
        if cmd:
            print(f"         Recommended: {cmd}", file=sys.stderr)


def build_projection_preflight_warnings(
    *,
    table_id: str,
    payload: Dict[str, Any],
    base_url: str,
    token: str,
    use_x_api_key: bool,
    timeout: int,
    verbose: bool,
    request_api_fn: Callable[..., Any],
    non_terminal_job_statuses: Set[str],
    terminal_job_statuses: Set[str],
    projection_readiness_sample_size: int = 50,
) -> List[Dict[str, Any]]:
    """Sample source columns and build warnings when they appear unprocessed."""

    items = payload.get("items")
    if not isinstance(items, list) or not items:
        return []

    source_ids: List[str] = []
    seen: set[str] = set()
    for item in items:
        if not isinstance(item, dict):
            continue
        raw_source = item.get("sourceColumnId") or item.get("source_column_id")
        source_id = str(raw_source or "").strip()
        if not source_id or source_id in seen:
            continue
        seen.add(source_id)
        source_ids.append(source_id)
    if not source_ids:
        return []

    columns_raw = request_api_fn(
        "GET",
        f"/api/tables/{table_id}/columns",
        base_url=base_url,
        token=token,
        use_x_api_key=use_x_api_key,
        timeout=timeout,
        verbose=verbose,
    )
    columns = extract_columns_list(columns_raw)
    by_id: Dict[str, Dict[str, Any]] = {}
    for col in columns:
        cid = str(col.get("id") or col.get("_id") or "").strip()
        if cid:
            by_id[cid] = col

    warnings: List[Dict[str, Any]] = []
    rows_sample: Optional[List[Dict[str, Any]]] = None
    jobs_cache: Dict[str, Optional[Dict[str, Any]]] = {}

    for source_id in source_ids:
        source_col = by_id.get(source_id)
        if not source_col or not is_json_enrichment_column(source_col):
            continue

        source_col_id = str(source_col.get("id") or source_col.get("_id") or source_id)
        source_key = str(source_col.get("key") or "").strip()
        recommended_next = _projection_recommended_run_command(table_id, source_col_id)

        latest_job = jobs_cache.get(source_col_id)
        if source_col_id not in jobs_cache:
            jobs_raw = request_api_fn(
                "GET",
                "/api/bulk-jobs",
                base_url=base_url,
                token=token,
                use_x_api_key=use_x_api_key,
                params={"table_id": table_id, "column_id": source_col_id, "limit": 1},
                timeout=timeout,
                verbose=verbose,
            )
            jobs = extract_jobs_list(jobs_raw)
            latest_job = jobs[0] if jobs else None
            jobs_cache[source_col_id] = latest_job

        latest_job_status = str((latest_job or {}).get("status") or "").strip().lower()
        if latest_job_status in non_terminal_job_statuses:
            warnings.append(
                _projection_preflight_warn(
                    code="source_job_not_terminal",
                    column=source_col,
                    reason=f"latest run status is '{latest_job_status}' (not terminal)",
                    recommended_next_command=recommended_next,
                    latest_job_status=latest_job_status,
                    column_status=str(source_col.get("status") or "").strip().lower() or None,
                )
            )
            continue
        if latest_job_status in terminal_job_statuses:
            continue

        has_run_metadata = bool(
            source_col.get("lastRunAt")
            or source_col.get("last_run_at")
            or source_col.get("lastJobId")
            or source_col.get("last_job_id")
        )
        if has_run_metadata:
            continue

        if rows_sample is None:
            rows_page_raw = request_api_fn(
                "GET",
                f"/api/tables/{table_id}/rows",
                base_url=base_url,
                token=token,
                use_x_api_key=use_x_api_key,
                params={"page_size": projection_readiness_sample_size},
                timeout=timeout,
                verbose=verbose,
            )
            if not isinstance(rows_page_raw, dict):
                rows_sample = []
            else:
                candidate_rows = rows_page_raw.get("rows") or []
                rows_sample = [row for row in candidate_rows if isinstance(row, dict)] if isinstance(candidate_rows, list) else []

        sample_count = 0
        if source_key:
            sample_count = sum(1 for row in (rows_sample or []) if is_processed_cell_value(row.get(source_key)))
        if sample_count > 0:
            continue

        warnings.append(
            _projection_preflight_warn(
                code="source_unrun_or_empty",
                column=source_col,
                reason="source JSON enrichment has no terminal run evidence and sampled values are empty",
                recommended_next_command=recommended_next,
                latest_job_status=latest_job_status or None,
                column_status=str(source_col.get("status") or "").strip().lower() or None,
                sample_rows_scanned=len(rows_sample or []),
                sample_non_empty_values=sample_count,
            )
        )

    return warnings


# ---------------------------------------------------------------------------
# Row selection (cursor-paginated)
# ---------------------------------------------------------------------------


def select_next_row_ids(
    *,
    table_id: str,
    column_id: str,
    count: int,
    filters: Optional[Dict[str, Any]],
    unprocessed_only: bool,
    page_size: int,
    base_url: str,
    token: str,
    use_x_api_key: bool,
    timeout: int,
    verbose: bool,
    request_api_fn: Callable[..., Any],
) -> Dict[str, Any]:
    """Cursor-paginated row selection loop returning up to *count* row IDs."""

    if count <= 0:
        return {"row_ids": [], "requested": 0, "selected": 0, "scanned_rows": 0}

    column_key = resolve_column_key(
        table_id=table_id,
        column_id=column_id,
        base_url=base_url,
        token=token,
        use_x_api_key=use_x_api_key,
        timeout=timeout,
        verbose=verbose,
        request_api_fn=request_api_fn,
    )

    selected: List[str] = []
    seen: set[str] = set()
    scanned_rows = 0
    page_count = 0
    cursor: Optional[str] = None

    effective_page_size = max(1, min(int(page_size or 200), 1000))
    filters_payload = filters if isinstance(filters, dict) else None

    while len(selected) < count:
        page_count += 1
        params: Dict[str, Any] = {"page_size": effective_page_size}
        if cursor:
            params["cursor"] = cursor
        if filters_payload:
            params["filters"] = json.dumps(filters_payload, separators=(",", ":"))

        page = request_api_fn(
            "GET",
            f"/api/tables/{table_id}/rows",
            base_url=base_url,
            token=token,
            use_x_api_key=use_x_api_key,
            params=params,
            timeout=timeout,
            verbose=verbose,
        )
        if not isinstance(page, dict):
            raise AutotouchAPIError(f"unexpected rows response: {page}")

        rows = page.get("rows") or []
        if not isinstance(rows, list):
            raise AutotouchAPIError(f"rows payload is not a list: {type(rows).__name__}")

        for row in rows:
            if not isinstance(row, dict):
                continue
            scanned_rows += 1
            row_id = str(row.get("_id") or row.get("id") or row.get("rowId") or "").strip()
            if not row_id or row_id in seen:
                continue
            seen.add(row_id)

            if unprocessed_only and is_processed_cell_value(row.get(column_key)):
                continue

            selected.append(row_id)
            if len(selected) >= count:
                break

        has_more = bool(page.get("hasMore") if "hasMore" in page else page.get("has_more"))
        next_cursor = page.get("nextCursor") if page.get("nextCursor") is not None else page.get("next_cursor")
        if len(selected) >= count:
            break
        if not has_more or not next_cursor:
            break
        cursor = str(next_cursor)

    return {
        "row_ids": selected,
        "requested": int(count),
        "selected": len(selected),
        "scanned_rows": scanned_rows,
        "pages_scanned": page_count,
        "column_key": column_key,
        "used_filters": bool(filters_payload),
        "unprocessed_only": bool(unprocessed_only),
    }


# ---------------------------------------------------------------------------
# Core run orchestrator
# ---------------------------------------------------------------------------


def execute_run_flow(
    *,
    args: argparse.Namespace,
    token: str,
    payload: Dict[str, Any],
    context: Optional[Dict[str, Any]] = None,
    request_api_fn: Callable[..., Any],
    print_json_fn: Callable[..., None],
    emit_progress_event_fn: Callable[..., None],
    api_url_fn: Callable[..., str],
    poll_job_fn: Callable[..., Dict[str, Any]],
    default_timeout_seconds: int = 30,
) -> None:
    """Core run orchestrator: estimate, credit guard, dry-run, POST, optional poll-wait."""

    estimate_data: Optional[Dict[str, Any]] = None
    should_estimate = bool(args.show_estimate or args.max_credits is not None or args.dry_run)
    if should_estimate:
        estimate_raw = request_api_fn(
            "POST",
            f"/api/tables/{args.table_id}/columns/{args.column_id}/estimate",
            base_url=args.base_url,
            token=token,
            use_x_api_key=args.use_x_api_key,
            payload=payload,
            timeout=args.timeout,
            verbose=args.verbose,
        )
        if not isinstance(estimate_raw, dict):
            raise AutotouchAPIError(f"unexpected estimate response: {estimate_raw}")
        estimate_data = estimate_raw

    if args.max_credits is not None:
        if estimate_data is None:
            raise AutotouchAPIError("failed to compute estimate for --max-credits guard")
        limit = float(args.max_credits)
        estimated_max = estimate_data.get("estimated_credits_max")
        estimated_min = float(estimate_data.get("estimated_credits_min") or 0.0)

        if estimated_max is None and not bool(args.allow_unknown_max):
            output = {
                "blocked": True,
                "reason": "estimated_credits_max is unknown; pass --allow-unknown-max to proceed",
                "max_credits_limit": limit,
                "estimate": estimate_data,
            }
            if context is not None:
                output["context"] = context
            print_json_fn(output, compact=args.compact)
            raise AutotouchCreditError(
                "estimated_credits_max is unknown; pass --allow-unknown-max to proceed"
            )

        compare_value = float(estimated_max if estimated_max is not None else estimated_min)
        if compare_value > limit:
            output = {
                "blocked": True,
                "reason": "estimated credits exceed max-credits limit",
                "max_credits_limit": limit,
                "estimate_compare_value": compare_value,
                "estimate": estimate_data,
            }
            if context is not None:
                output["context"] = context
            print_json_fn(output, compact=args.compact)
            raise AutotouchCreditError(
                "estimated credits exceed max-credits limit"
            )

    if args.dry_run:
        output = {
            "dry_run": True,
            "run_payload": payload,
            "estimate": estimate_data,
        }
        if context is not None:
            output["context"] = context
        print_json_fn(output, compact=args.compact)
        return

    run_data = request_api_fn(
        "POST",
        f"/api/tables/{args.table_id}/columns/{args.column_id}/run",
        base_url=args.base_url,
        token=token,
        use_x_api_key=args.use_x_api_key,
        payload=payload,
        timeout=args.timeout,
        verbose=args.verbose,
    )
    if not isinstance(run_data, dict):
        output_non_dict: Any = run_data
        if context is not None:
            output_non_dict = {"context": context, "run": run_data}
        print_json_fn(output_non_dict, compact=args.compact)
        return

    if args.wait:
        job_id = run_data.get("job_id") or run_data.get("jobId")
        if not job_id:
            output = run_data if context is None else {"context": context, "run": run_data}
            print_json_fn(output, compact=args.compact)
            raise AutotouchAPIError("run response missing job_id; cannot wait")
        job_id = str(job_id)
        status_url = f"{api_url_fn(args.base_url)}/api/bulk-jobs/{job_id}"
        watch_command = f"autotouch jobs watch --job-id {job_id}"
        if not args.quiet_wait:
            emit_progress_event_fn(
                {
                    "event": "run.wait_started",
                    "job_id": job_id,
                    "status": "polling_started",
                    "status_url": status_url,
                    "watch_command": watch_command,
                    "hint": "polling /api/bulk-jobs/{job_id}",
                },
                compact=args.compact,
            )

        poll_result = poll_job_fn(
            job_id=job_id,
            base_url=args.base_url,
            token=token,
            use_x_api_key=args.use_x_api_key,
            interval_seconds=int(args.poll_interval or 2),
            wait_timeout_seconds=int(args.wait_timeout or 0),
            request_timeout_seconds=int(args.timeout or default_timeout_seconds),
            verbose=args.verbose,
            compact=args.compact,
            once=False,
            print_updates=not args.quiet_wait,
        )
        final_job = poll_result.get("job") or {}
        timed_out = bool(poll_result.get("timed_out"))
        final_status = str((final_job or {}).get("status") or "").lower()
        output = {
            "event": "run.timed_out" if timed_out else "run.completed",
            "run": run_data,
            "estimate": estimate_data if args.show_estimate or args.max_credits is not None else None,
            "job": final_job,
            "final_job": final_job,
            "job_id": job_id,
            "job_status_url": status_url,
            "watch_command": watch_command,
            "status": final_status,
            "timed_out": timed_out,
            "polls": int(poll_result.get("polls") or 0),
        }
        if isinstance(final_job, dict):
            output["post_run_summary"] = _post_run_reconciliation_summary(
                args=args,
                payload=payload,
                job=final_job,
                token=token,
                request_api_fn=request_api_fn,
            )
        if context is not None:
            output["context"] = context
        print_json_fn(output, compact=args.compact)

        if timed_out:
            raise AutotouchTimeoutError("job poll timed out before reaching terminal status")
        if final_status in {"not_found", "unknown_not_found"}:
            raise AutotouchNotFoundError(
                f"job {job_id} disappeared before terminal status; verify row state and rerun if needed"
            )
        if args.fail_on_error and final_status in {"error", "cancelled"}:
            raise AutotouchAPIError(f"job {job_id} ended with status: {final_status}")
        if args.fail_on_partial and final_status == "partial":
            raise AutotouchAPIError(f"job {job_id} ended with partial status")
        return

    job_id = run_data.get("job_id") or run_data.get("jobId")
    status_url = None
    watch_command = None
    if job_id:
        status_url = f"{api_url_fn(args.base_url)}/api/bulk-jobs/{job_id}"
        watch_command = f"autotouch jobs watch --job-id {job_id}"

    output_any: Any = run_data
    if estimate_data is not None and args.show_estimate:
        output_any = {"estimate": estimate_data, "run": run_data}
    if context is not None:
        output_any = {"context": context, "result": output_any}
    if isinstance(output_any, dict):
        output_any = dict(output_any)
        output_any.setdefault("event", "run.queued")
        if job_id:
            output_any.setdefault("job_id", str(job_id))
            output_any["job_status_url"] = status_url
            output_any["watch_command"] = watch_command
    elif job_id:
        output_any = {
            "event": "run.queued",
            "job_id": str(job_id),
            "job_status_url": status_url,
            "watch_command": watch_command,
            "result": output_any,
        }
    print_json_fn(output_any, compact=args.compact)
