from __future__ import annotations

import argparse
import json
import os
import sys
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Sequence

from autotouch_cli.exceptions import (
    AutotouchAPIError,
    AutotouchError,
    AutotouchInputError,
    AutotouchNotFoundError,
)


@dataclass(frozen=True)
class RowCommandRuntime:
    resolve_token: Callable[[Optional[str], bool], Optional[str]]
    request_api: Callable[..., Any]
    request_multipart_api: Callable[..., Any]
    print_json: Callable[[Any, bool], None]
    load_json_input: Callable[..., Any]
    api_url: Callable[[Optional[str]], str]
    create_rows_and_patch_records: Callable[..., Dict[str, Any]]
    read_records_from_csv: Callable[..., List[Dict[str, Any]]]
    collect_import_assertions: Callable[[argparse.Namespace], Dict[str, Any]]
    has_import_assertions: Callable[[Dict[str, Any]], bool]
    evaluate_import_assertions_on_records: Callable[..., Dict[str, Any]]
    build_import_verify_query_params: Callable[[Dict[str, Any]], Dict[str, Any]]
    poll_import_status: Callable[..., Dict[str, Any]]
    assertion_failure_exit_code: int
    default_timeout_seconds: int


def _parse_json_cell_values(value: Any) -> Any:
    if isinstance(value, str):
        stripped = value.strip()
        if stripped[:1] in {"{", "["}:
            try:
                parsed = json.loads(stripped)
            except Exception:
                return value
            return _parse_json_cell_values(parsed)
        return value
    if isinstance(value, list):
        return [_parse_json_cell_values(item) for item in value]
    if isinstance(value, dict):
        return {key: _parse_json_cell_values(item) for key, item in value.items()}
    return value


def cmd_rows_list(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    token = runtime.resolve_token(args.token, required=True)
    filters = runtime.load_json_input(
        inline_json=getattr(args, "filters_json", None),
        file_path=getattr(args, "filters_file", None),
        context="filters",
        default=None,
    )
    if filters is not None and not isinstance(filters, dict):
        raise AutotouchInputError("filters must be a JSON object")

    sort = runtime.load_json_input(
        inline_json=getattr(args, "sort_json", None),
        file_path=getattr(args, "sort_file", None),
        context="sort",
        default=None,
    )
    if sort is not None and not isinstance(sort, dict):
        raise AutotouchInputError("sort must be a JSON object")

    params: Dict[str, Any] = {"page_size": int(args.page_size or 50)}
    if filters is not None:
        params["filters"] = json.dumps(filters)
    if sort is not None:
        params["sort"] = json.dumps(sort)
    if getattr(args, "cursor", None):
        params["cursor"] = args.cursor

    data = runtime.request_api(
        "GET",
        f"/api/tables/{args.table_id}/rows",
        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 bool(getattr(args, "parse_json_cells", False)):
        data = _parse_json_cell_values(data)
    runtime.print_json(data, args.compact)


def cmd_rows_get(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    token = runtime.resolve_token(args.token, required=True)
    data = runtime.request_api(
        "GET",
        f"/api/tables/{args.table_id}/rows/{args.row_id}",
        base_url=args.base_url,
        token=token,
        use_x_api_key=args.use_x_api_key,
        timeout=args.timeout,
        verbose=args.verbose,
    )
    if bool(getattr(args, "parse_json_cells", False)):
        data = _parse_json_cell_values(data)
    runtime.print_json(data, args.compact)


def cmd_rows_add(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    token = runtime.resolve_token(args.token, required=True)
    records_payload = runtime.load_json_input(
        inline_json=args.records_json,
        file_path=args.records_file,
        context="records",
        default=None,
    )

    if records_payload is not None:
        records: List[Dict[str, Any]]
        if isinstance(records_payload, dict):
            if isinstance(records_payload.get("records"), list):
                records = records_payload.get("records")  # type: ignore[assignment]
            else:
                records = [records_payload]
        elif isinstance(records_payload, list):
            records = records_payload
        else:
            raise AutotouchInputError("records payload must be an object, list, or {records:[...]}")
    else:
        records = None

    output = runtime.create_rows_and_patch_records(
        table_id=args.table_id,
        records=records,
        blank_count=int(args.count or 1),
        base_url=args.base_url,
        token=token,
        use_x_api_key=args.use_x_api_key,
        timeout=args.timeout,
        verbose=args.verbose,
        detect_types=bool(args.detect_types),
    )
    runtime.print_json(output, args.compact)
    if isinstance(output, dict) and bool(output.get("patchFailed")):
        raise AutotouchAPIError("row add patch failed")


def cmd_rows_delete(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    token = runtime.resolve_token(args.token, required=True)
    data = runtime.request_api(
        "DELETE",
        f"/api/tables/{args.table_id}/rows/{args.row_id}",
        base_url=args.base_url,
        token=token,
        use_x_api_key=args.use_x_api_key,
        timeout=args.timeout,
        verbose=args.verbose,
    )
    runtime.print_json(data, args.compact)


def cmd_rows_import_csv(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    if getattr(args, "confirm_table_id", None):
        expected_table_id = str(args.confirm_table_id).strip()
        actual_table_id = str(args.table_id).strip()
        if expected_table_id and expected_table_id != actual_table_id:
            raise AutotouchInputError(f"--confirm-table-id mismatch (expected {expected_table_id}, got {actual_table_id})")

    token = runtime.resolve_token(args.token, required=True)
    transport = str(getattr(args, "transport", "optimized") or "optimized").strip().lower()
    source_path = os.path.abspath(os.path.expanduser(str(args.file)))
    base_url_resolved = runtime.api_url(args.base_url)
    assertions = runtime.collect_import_assertions(args)
    assertions_enabled = runtime.has_import_assertions(assertions)
    preflight_cache: Optional[Dict[str, Any]] = None

    csv_read_options = {
        "delimiter": args.delimiter,
        "encoding": args.encoding,
        "trim_headers": bool(args.trim_headers),
        "trim_values": bool(args.trim_values),
        "empty_as_null": bool(args.empty_as_null),
        "skip_empty_rows": bool(args.skip_empty_rows),
        "limit": (int(args.limit) if args.limit else None),
    }

    source_meta: Dict[str, Any] = {
        "path": source_path,
        "transport": transport,
    }
    try:
        stat = os.stat(source_path)
        source_meta["size"] = int(stat.st_size)
        source_meta["mtime_epoch"] = int(stat.st_mtime)
    except OSError:
        pass

    checkpoint_file = str(getattr(args, "checkpoint_file", "") or "").strip()
    allow_reimport = bool(getattr(args, "allow_reimport", False))

    def run_local_preflight() -> Optional[Dict[str, Any]]:
        nonlocal preflight_cache
        if not assertions_enabled:
            return None
        if preflight_cache is None:
            records = runtime.read_records_from_csv(source_path, **csv_read_options)
            preflight_cache = runtime.evaluate_import_assertions_on_records(
                records=records,
                assertions=assertions,
                source="local_csv_preflight",
            )
        return preflight_cache

    def run_postflight_verify(task_id: str) -> Dict[str, Any]:
        params = runtime.build_import_verify_query_params(assertions)
        return runtime.request_api(
            "GET",
            f"/api/tables/{args.table_id}/import-verify/{task_id}",
            base_url=args.base_url,
            token=token,
            use_x_api_key=args.use_x_api_key,
            params=params or None,
            timeout=args.timeout,
            verbose=args.verbose,
        )

    def load_import_checkpoint() -> Optional[Dict[str, Any]]:
        if not checkpoint_file:
            return None
        try:
            with open(checkpoint_file, "r", encoding="utf-8") as f:
                data = json.load(f)
            return data if isinstance(data, dict) else None
        except FileNotFoundError:
            return None
        except Exception as exc:
            print(f"WARN: failed to read checkpoint file {checkpoint_file}: {exc}", file=sys.stderr)
            return None

    def write_import_checkpoint(status: str, *, task_id: Optional[str] = None, detail: Optional[Dict[str, Any]] = None) -> None:
        if not checkpoint_file:
            return
        payload = {
            "table_id": str(args.table_id),
            "source": source_meta,
            "status": status,
            "task_id": task_id,
            "updated_at_epoch": int(time.time()),
            "detail": detail or {},
        }
        try:
            with open(checkpoint_file, "w", encoding="utf-8") as f:
                json.dump(payload, f, indent=2)
        except Exception as exc:
            print(f"WARN: failed to write checkpoint file {checkpoint_file}: {exc}", file=sys.stderr)

    if bool(getattr(args, "validate_only", False)):
        validation = runtime.request_multipart_api(
            "POST",
            f"/api/tables/{args.table_id}/csv-validate",
            base_url=args.base_url,
            token=token,
            use_x_api_key=args.use_x_api_key,
            file_path=source_path,
            file_field="file",
            form_fields={"sample_size": int(getattr(args, "sample_size", 5) or 5)},
            timeout=args.timeout,
            verbose=args.verbose,
        )
        output: Dict[str, Any] = {
            "validate_only": True,
            "table_id": args.table_id,
            "base_url": base_url_resolved,
            "source": source_path,
            "validation": validation,
        }
        preflight = run_local_preflight()
        if preflight is not None:
            output["assertions"] = {"preflight": preflight}
        runtime.print_json(output, args.compact)
        if preflight is not None and not bool(preflight.get("passed")):
            raise AutotouchError("import assertions failed (preflight)", exit_code=runtime.assertion_failure_exit_code)
        return

    existing_checkpoint = load_import_checkpoint()
    if existing_checkpoint and not allow_reimport:
        same_table = str(existing_checkpoint.get("table_id") or "") == str(args.table_id)
        cp_source = existing_checkpoint.get("source") if isinstance(existing_checkpoint.get("source"), dict) else {}
        same_source = bool(cp_source) and str(cp_source.get("path") or "") == source_path
        checkpoint_status = str(existing_checkpoint.get("status") or "").strip().lower()
        checkpoint_task_id = existing_checkpoint.get("task_id")
        if same_table and same_source:
            if checkpoint_status in {"completed", "optimized-sync", "direct", "direct-completed"}:
                output = {
                    "skipped": True,
                    "reason": "checkpoint indicates this file already imported for this table",
                    "table_id": args.table_id,
                    "source": source_path,
                    "checkpoint": existing_checkpoint,
                }
                runtime.print_json(output, args.compact)
                return
            if checkpoint_status in {"queued", "processing", "running"} and checkpoint_task_id and transport == "optimized":
                if not getattr(args, "wait", False):
                    output = {
                        "skipped": True,
                        "reason": "checkpoint has in-flight import task; use --wait or --allow-reimport",
                        "table_id": args.table_id,
                        "source": source_path,
                        "checkpoint": existing_checkpoint,
                        "next": f"autotouch rows import-status --table-id {args.table_id} --task-id {checkpoint_task_id}",
                    }
                    runtime.print_json(output, args.compact)
                    return
                poll_result = runtime.poll_import_status(
                    table_id=args.table_id,
                    task_id=str(checkpoint_task_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 runtime.default_timeout_seconds),
                    verbose=args.verbose,
                    compact=args.compact,
                )
                final_status = poll_result.get("status") or {}
                final_status_value = str((final_status or {}).get("status") or "").lower()
                if final_status_value in {"completed", "partial"}:
                    write_import_checkpoint(
                        "completed",
                        task_id=str(checkpoint_task_id),
                        detail={"final_status": final_status},
                    )
                elif final_status_value in {"failed", "error"}:
                    write_import_checkpoint(
                        "failed",
                        task_id=str(checkpoint_task_id),
                        detail={"final_status": final_status},
                    )
                output = {
                    "resumed_from_checkpoint": True,
                    "transport": "optimized-async",
                    "task_id": str(checkpoint_task_id),
                    "final_status": final_status,
                    "timed_out": bool(poll_result.get("timed_out")),
                    "polls": int(poll_result.get("polls") or 0),
                    "table_id": args.table_id,
                    "base_url": base_url_resolved,
                    "source": source_path,
                }
                verification: Optional[Dict[str, Any]] = None
                if assertions_enabled and final_status_value in {"completed", "partial"}:
                    verification = run_postflight_verify(str(checkpoint_task_id))
                    output["verification"] = verification
                runtime.print_json(output, args.compact)
                if bool(poll_result.get("timed_out")):
                    raise AutotouchNotFoundError("import poll timed out waiting for terminal status")
                if final_status_value == "failed":
                    raise AutotouchAPIError("import task failed")
                if verification is not None and not bool(verification.get("passed", False)):
                    raise AutotouchError("import assertions failed (postflight)", exit_code=runtime.assertion_failure_exit_code)
                return

    if getattr(args, "dry_run", False):
        records = runtime.read_records_from_csv(source_path, **csv_read_options)
        sample_keys: List[str] = []
        if records and isinstance(records[0], dict):
            sample_keys = list(records[0].keys())
        output = {
            "dry_run": True,
            "table_id": args.table_id,
            "base_url": base_url_resolved,
            "transport": transport,
            "source": source_path,
            "recordsParsed": len(records),
            "sampleKeys": sample_keys,
            "limit": csv_read_options.get("limit"),
            "next": "Run again without --dry-run to import",
        }
        preflight = run_local_preflight()
        if preflight is not None:
            output["assertions"] = {"preflight": preflight}
        write_import_checkpoint(
            "dry-run",
            detail={
                "recordsParsed": len(records),
                "sampleKeys": sample_keys,
                "transport": transport,
            },
        )
        runtime.print_json(output, args.compact)
        if preflight is not None and not bool(preflight.get("passed")):
            raise AutotouchError("import assertions failed (preflight)", exit_code=runtime.assertion_failure_exit_code)
        return

    if transport == "direct":
        preflight = run_local_preflight()
        if preflight is not None and not bool(preflight.get("passed")):
            runtime.print_json(
                {
                    "table_id": args.table_id,
                    "source": source_path,
                    "assertions": {"preflight": preflight},
                    "aborted": True,
                    "reason": "preflight assertions failed",
                },
                args.compact,
            )
            raise AutotouchError("preflight assertions failed", exit_code=runtime.assertion_failure_exit_code)

        records = runtime.read_records_from_csv(source_path, **csv_read_options)
        if not records:
            output = {
                "created": 0,
                "rowIds": [],
                "updatedCells": 0,
                "patchResult": None,
                "recordsParsed": 0,
                "source": source_path,
                "table_id": args.table_id,
                "base_url": base_url_resolved,
            }
            if preflight is not None:
                output["assertions"] = {"preflight": preflight}
            write_import_checkpoint("direct", detail=output)
            runtime.print_json(output, args.compact)
            return

        output = runtime.create_rows_and_patch_records(
            table_id=args.table_id,
            records=records,
            blank_count=0,
            base_url=args.base_url,
            token=token,
            use_x_api_key=args.use_x_api_key,
            timeout=args.timeout,
            verbose=args.verbose,
            detect_types=bool(args.detect_types),
        )
        output["recordsParsed"] = len(records)
        output["source"] = source_path
        output["transport"] = "direct"
        output["table_id"] = args.table_id
        output["base_url"] = base_url_resolved
        if preflight is not None:
            postflight_passed = True
            postflight_failures: List[str] = []
            expected_rows = assertions.get("expected_rows")
            if expected_rows is not None and int(output.get("created") or 0) != int(expected_rows):
                postflight_passed = False
                postflight_failures.append(
                    f"Expected created rows={expected_rows} but got {output.get('created')}"
                )
            output["assertions"] = {
                "preflight": preflight,
                "postflight": {
                    "expected_rows": expected_rows,
                    "created_rows": int(output.get("created") or 0),
                    "passed": postflight_passed,
                    "failures": postflight_failures,
                },
            }
        write_import_checkpoint(
            "direct-completed",
            detail={
                "recordsParsed": len(records),
                "created": output.get("created"),
                "updatedCells": output.get("updatedCells"),
            },
        )
        runtime.print_json(output, args.compact)
        if preflight is not None and not bool(output.get("assertions", {}).get("postflight", {}).get("passed", True)):
            raise AutotouchError("import assertions failed (postflight)", exit_code=runtime.assertion_failure_exit_code)
        return

    preflight = run_local_preflight()
    if preflight is not None and not bool(preflight.get("passed")):
        runtime.print_json(
            {
                "table_id": args.table_id,
                "source": source_path,
                "assertions": {"preflight": preflight},
                "aborted": True,
                "reason": "preflight assertions failed",
            },
            args.compact,
        )
        raise AutotouchError("preflight assertions failed", exit_code=runtime.assertion_failure_exit_code)

    field_mappings = runtime.load_json_input(
        inline_json=getattr(args, "field_mappings_json", None),
        file_path=getattr(args, "field_mappings_file", None),
        context="field-mappings",
        default=None,
    )
    if field_mappings is not None and not isinstance(field_mappings, dict):
        raise AutotouchInputError("field mappings must be a JSON object")

    form_fields: Dict[str, Any] = {
        "origin": str(getattr(args, "origin", "csv") or "csv"),
        "check_company_blacklist": bool(getattr(args, "check_company_blacklist", True)),
        "check_email_blacklist": bool(getattr(args, "check_email_blacklist", False)),
    }
    if field_mappings is not None:
        form_fields["field_mappings"] = json.dumps(field_mappings)

    ingest_mode = str(getattr(args, "ingest_mode", None) or "").strip()
    if ingest_mode:
        ingest_policy: Dict[str, Any] = {"mode": ingest_mode}
        match_rule_id = str(getattr(args, "match_rule_id", None) or "").strip()
        match_column = str(getattr(args, "match_column", None) or "").strip()
        match_strategy = str(getattr(args, "match_strategy", None) or "").strip()
        merge_mode = str(getattr(args, "merge_mode", None) or "").strip()
        if match_rule_id:
            ingest_policy["rule_id"] = match_rule_id
        if match_column:
            ingest_policy["column_key"] = match_column
        if match_strategy:
            ingest_policy["match_strategy"] = match_strategy
        if merge_mode:
            ingest_policy["merge_mode"] = merge_mode
        form_fields["ingest_policy"] = json.dumps(ingest_policy)

    use_async = bool(getattr(args, "use_async", True))
    params = {"use_async": "true" if use_async else "false"}
    response = runtime.request_multipart_api(
        "POST",
        f"/api/tables/{args.table_id}/import-optimized",
        base_url=args.base_url,
        token=token,
        use_x_api_key=args.use_x_api_key,
        file_path=source_path,
        file_field="file",
        form_fields=form_fields,
        params=params,
        timeout=args.timeout,
        verbose=args.verbose,
    )

    if not use_async:
        if isinstance(response, dict):
            response["transport"] = "optimized-sync"
            response["table_id"] = args.table_id
            response["base_url"] = base_url_resolved
            response["source"] = source_path
            if preflight is not None:
                postflight_passed = True
                postflight_failures: List[str] = []
                expected_rows = assertions.get("expected_rows")
                actual_rows = int(response.get("total_rows") or 0)
                if expected_rows is not None and actual_rows != int(expected_rows):
                    postflight_passed = False
                    postflight_failures.append(
                        f"Expected total_rows={expected_rows} but got {actual_rows}"
                    )
                response["assertions"] = {
                    "preflight": preflight,
                    "postflight": {
                        "expected_rows": expected_rows,
                        "actual_rows": actual_rows,
                        "passed": postflight_passed,
                        "failures": postflight_failures,
                    },
                }
            write_import_checkpoint("optimized-sync", detail=response)
        runtime.print_json(response, args.compact)
        if (
            isinstance(response, dict)
            and preflight is not None
            and not bool(response.get("assertions", {}).get("postflight", {}).get("passed", True))
        ):
            raise AutotouchError("import assertions failed (postflight)", exit_code=runtime.assertion_failure_exit_code)
        return

    if not isinstance(response, dict):
        runtime.print_json(response, args.compact)
        return

    task_id = response.get("task_id") or response.get("taskId")
    if not task_id:
        runtime.print_json(response, args.compact)
        return

    if not getattr(args, "wait", False):
        response["transport"] = "optimized-async"
        response["next"] = f"autotouch rows import-status --table-id {args.table_id} --task-id {task_id}"
        response["table_id"] = args.table_id
        response["base_url"] = base_url_resolved
        response["source"] = source_path
        if preflight is not None:
            response["assertions"] = {"preflight": preflight}
        if assertions_enabled:
            response["verify_next"] = (
                f"autotouch rows import-verify --table-id {args.table_id} --task-id {task_id}"
            )
        write_import_checkpoint(
            "queued",
            task_id=str(task_id),
            detail={"queued": response},
        )
        runtime.print_json(response, args.compact)
        return

    poll_result = runtime.poll_import_status(
        table_id=args.table_id,
        task_id=str(task_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 runtime.default_timeout_seconds),
        verbose=args.verbose,
        compact=args.compact,
    )
    final_status = poll_result.get("status") or {}
    timed_out = bool(poll_result.get("timed_out"))
    output = {
        "transport": "optimized-async",
        "task_id": str(task_id),
        "queued": response,
        "final_status": final_status,
        "timed_out": timed_out,
        "polls": int(poll_result.get("polls") or 0),
        "table_id": args.table_id,
        "base_url": base_url_resolved,
        "source": source_path,
    }
    if preflight is not None:
        output["assertions"] = {"preflight": preflight}
    final_status_value = str((final_status or {}).get("status") or "").lower()
    if timed_out:
        write_import_checkpoint(
            "running",
            task_id=str(task_id),
            detail={"final_status": final_status, "timed_out": True},
        )
    elif final_status_value in {"completed", "partial"}:
        write_import_checkpoint(
            "completed",
            task_id=str(task_id),
            detail={"final_status": final_status},
        )
    elif final_status_value in {"failed", "error"}:
        write_import_checkpoint(
            "failed",
            task_id=str(task_id),
            detail={"final_status": final_status},
        )
    else:
        write_import_checkpoint(
            "running",
            task_id=str(task_id),
            detail={"final_status": final_status},
        )
    verification: Optional[Dict[str, Any]] = None
    if assertions_enabled and final_status_value in {"completed", "partial"}:
        verification = run_postflight_verify(str(task_id))
        output["verification"] = verification
    runtime.print_json(output, args.compact)

    if timed_out:
        raise AutotouchNotFoundError("import poll timed out waiting for terminal status")
    if final_status_value == "failed":
        raise AutotouchAPIError("import task failed")
    if verification is not None and not bool(verification.get("passed", False)):
        raise AutotouchError("import assertions failed (postflight)", exit_code=runtime.assertion_failure_exit_code)


def cmd_rows_import_status(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    token = runtime.resolve_token(args.token, required=True)
    if getattr(args, "wait", False):
        poll_result = runtime.poll_import_status(
            table_id=args.table_id,
            task_id=args.task_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 runtime.default_timeout_seconds),
            verbose=args.verbose,
            compact=args.compact,
        )
        final_status = poll_result.get("status") or {}
        timed_out = bool(poll_result.get("timed_out"))
        output = {
            "task_id": args.task_id,
            "final_status": final_status,
            "timed_out": timed_out,
            "polls": int(poll_result.get("polls") or 0),
        }
        runtime.print_json(output, args.compact)
        if timed_out:
            raise AutotouchNotFoundError("import status poll timed out waiting for terminal status")
        if str((final_status or {}).get("status") or "").lower() == "failed":
            raise AutotouchAPIError("import task failed")
        return

    data = runtime.request_api(
        "GET",
        f"/api/tables/{args.table_id}/import-status/{args.task_id}",
        base_url=args.base_url,
        token=token,
        use_x_api_key=args.use_x_api_key,
        timeout=args.timeout,
        verbose=args.verbose,
    )
    runtime.print_json(data, args.compact)


def cmd_rows_import_verify(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    token = runtime.resolve_token(args.token, required=True)
    assertions = runtime.collect_import_assertions(args)
    params = runtime.build_import_verify_query_params(assertions)
    data = runtime.request_api(
        "GET",
        f"/api/tables/{args.table_id}/import-verify/{args.task_id}",
        base_url=args.base_url,
        token=token,
        use_x_api_key=args.use_x_api_key,
        params=params or None,
        timeout=args.timeout,
        verbose=args.verbose,
    )
    runtime.print_json(data, args.compact)
    if isinstance(data, dict) and not bool(data.get("passed", False)):
        raise AutotouchError("import verification assertions failed", exit_code=runtime.assertion_failure_exit_code)


def cmd_rows_import_rollback(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    token = runtime.resolve_token(args.token, required=True)
    params: Dict[str, Any] = {}
    if bool(getattr(args, "dry_run", False)):
        params["dry_run"] = "true"
    data = runtime.request_api(
        "POST",
        f"/api/tables/{args.table_id}/import-rollback/{args.task_id}",
        base_url=args.base_url,
        token=token,
        use_x_api_key=args.use_x_api_key,
        params=params or None,
        timeout=args.timeout,
        verbose=args.verbose,
    )
    runtime.print_json(data, args.compact)
    if isinstance(data, dict):
        failed_count = int(data.get("failed_count") or 0)
        if failed_count > 0:
            raise AutotouchAPIError(f"import rollback had {failed_count} failures")


def cmd_rows_dedupe(args: argparse.Namespace, *, runtime: RowCommandRuntime) -> None:
    token = runtime.resolve_token(args.token, required=True)
    stats_only = bool(getattr(args, "stats_only", False))
    output_format = str(getattr(args, "output", "json") or "json").strip().lower()

    if stats_only:
        data = runtime.request_api(
            "GET",
            f"/api/tables/{args.table_id}/dedupe/{args.column_key}/stats",
            base_url=args.base_url,
            token=token,
            use_x_api_key=args.use_x_api_key,
            timeout=args.timeout,
            verbose=args.verbose,
        )
        if output_format == "json" or not isinstance(data, dict):
            runtime.print_json(data, args.compact)
            return
        total_rows = data.get("total_rows", "?")
        unique_values = data.get("unique_values", "?")
        rows_to_delete = data.get("rows_to_delete", "?")
        print(f"Column:         {args.column_key}")
        print(f"Total rows:     {total_rows}")
        print(f"Unique values:  {unique_values}")
        print(f"Rows to delete: {rows_to_delete}")
        print(f"(Run again without --stats-only / --dry-run to delete duplicates)")
    else:
        data = runtime.request_api(
            "POST",
            f"/api/tables/{args.table_id}/dedupe",
            base_url=args.base_url,
            token=token,
            use_x_api_key=args.use_x_api_key,
            payload={"column_key": args.column_key},
            timeout=args.timeout,
            verbose=args.verbose,
        )
        if output_format == "json" or not isinstance(data, dict):
            runtime.print_json(data, args.compact)
            return
        rows_deleted = data.get("rows_deleted", data.get("deleted", "?"))
        print(f"Dedupe complete: {rows_deleted} duplicate rows deleted from column '{args.column_key}'")


def register_rows_subcommands(
    subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
    *,
    add_api_common_arguments: Callable[[argparse.ArgumentParser], None],
    add_output_formatting_arguments: Callable[[argparse.ArgumentParser], None],
    handlers: Dict[str, Callable[[argparse.Namespace], None]],
    row_schema_types: Sequence[str],
) -> None:
    pr = subparsers.add_parser("rows", help="Row operations")
    rows_sub = pr.add_subparsers(dest="rows_cmd", required=True)

    prl = rows_sub.add_parser("list", help="List table rows with optional filters/sort")
    prl.add_argument("--table-id", required=True)
    prl.add_argument("--filters-json", help="Optional JSON object for server-side filters")
    prl.add_argument("--filters-file", help="Optional JSON file for server-side filters")
    prl.add_argument("--sort-json", help="Optional JSON object for server-side sort")
    prl.add_argument("--sort-file", help="Optional JSON file for server-side sort")
    prl.add_argument("--page-size", type=int, default=50, help="Rows per page (1-1000, default 50)")
    prl.add_argument("--cursor", help="Pagination cursor from a previous rows list response")
    prl.add_argument(
        "--parse-json-cells",
        action="store_true",
        help="Parse stringified JSON object/array cell values before printing output",
    )
    add_api_common_arguments(prl)
    prl.set_defaults(func=handlers["list"])

    prg = rows_sub.add_parser("get", help="Get one flattened row by id")
    prg.add_argument("--table-id", required=True)
    prg.add_argument("--row-id", required=True)
    prg.add_argument(
        "--parse-json-cells",
        action="store_true",
        help="Parse stringified JSON object/array cell values before printing output",
    )
    add_api_common_arguments(prg)
    prg.set_defaults(func=handlers["get"])

    pra = rows_sub.add_parser("add", help="Add rows (blank count or records payload)")
    pra.add_argument("--table-id", required=True)
    pra.add_argument("--count", type=int, default=1, help="Blank rows to create (ignored if records provided)")
    pra.add_argument("--records-json", help="Object, list, or {records:[...]} payload")
    pra.add_argument("--records-file", help="Path to JSON file containing records payload")
    pra.add_argument("--detect-types", action="store_true", help="Enable detect_types when patching record values")
    add_api_common_arguments(pra)
    pra.set_defaults(func=handlers["add"])

    pric = rows_sub.add_parser("import-csv", help="Import CSV rows by creating rows + patching cells")
    pric.add_argument("--table-id", required=True)
    pric.add_argument("--confirm-table-id", help="Safety check: must match --table-id or command exits")
    pric.add_argument("--file", required=True, help="CSV file path")
    pric.add_argument(
        "--transport",
        choices=["optimized", "direct"],
        default="optimized",
        help="Import transport: optimized uses /import-optimized (recommended); direct uses row/cell API loop",
    )
    pric.add_argument("--dry-run", action="store_true", help="Parse CSV and print summary without creating rows")
    pric.add_argument(
        "--validate-only",
        action="store_true",
        help="Server-side CSV parse/shape validation only (no writes)",
    )
    pric.add_argument(
        "--sample-size",
        type=int,
        default=5,
        help="Sample rows returned by --validate-only (0-20, default: 5)",
    )
    pric.add_argument(
        "--checkpoint-file",
        help="Optional JSON checkpoint file (guards duplicate imports unless --allow-reimport)",
    )
    pric.add_argument("--allow-reimport", action="store_true", help="Ignore checkpoint guard and import again")
    pric.add_argument("--expected-rows", type=int, help="Assert row count equals this value")
    pric.add_argument(
        "--require-columns",
        action="append",
        help="Assert required column keys exist (comma-separated; repeatable)",
    )
    pric.add_argument(
        "--duplicate-key",
        action="append",
        help="Assert no duplicates on this identity key (comma-separated; repeatable)",
    )
    pric.add_argument(
        "--require-non-empty",
        action="append",
        help="Assert non-empty ratio by key (key:ratio, comma-separated; repeatable)",
    )
    pric.add_argument("--delimiter", default=",", help="CSV delimiter (default: ,)")
    pric.add_argument("--encoding", default="utf-8", help="File encoding (default: utf-8)")
    pric.add_argument("--limit", type=int, help="Optional max records to import")
    pric.add_argument("--no-trim-headers", dest="trim_headers", action="store_false", help="Do not trim header whitespace")
    pric.add_argument("--trim-values", action="store_true", help="Trim cell string values")
    pric.add_argument("--no-empty-as-null", dest="empty_as_null", action="store_false", help="Preserve empty strings instead of null")
    pric.add_argument("--no-skip-empty-rows", dest="skip_empty_rows", action="store_false", help="Keep fully empty rows")
    pric.add_argument("--detect-types", action="store_true", help="Enable detect_types while patching")
    pric.add_argument("--origin", default="csv", help="Column origin tag for optimized import")
    pric.add_argument("--field-mappings-json", help="Field mappings JSON object for optimized import")
    pric.add_argument("--field-mappings-file", help="Field mappings JSON file for optimized import")
    pric.add_argument(
        "--no-check-company-blacklist",
        dest="check_company_blacklist",
        action="store_false",
        help="Disable company blacklist filtering for optimized import",
    )
    pric.add_argument(
        "--check-email-blacklist",
        dest="check_email_blacklist",
        action="store_true",
        help="Enable email blacklist filtering for optimized import",
    )
    pric.add_argument(
        "--sync",
        dest="use_async",
        action="store_false",
        help="Use synchronous /import-optimized processing instead of background task mode",
    )
    pric.add_argument("--wait", action="store_true", help="Wait for async optimized import to complete")
    pric.add_argument("--poll-interval", type=int, default=2, help="Polling interval seconds for --wait")
    pric.add_argument("--wait-timeout", type=int, default=0, help="Max seconds to wait (0 = no timeout)")
    _INGEST_MODES = ["allow", "skip", "upsert"]
    _MATCH_STRATEGIES = ["email", "domain", "linkedin_url", "external_id", "exact_text"]
    _MERGE_MODES = ["fill_empty", "overwrite_mapped"]
    pric.add_argument(
        "--ingest-mode",
        choices=_INGEST_MODES,
        default=None,
        help="Duplicate handling: allow / skip / upsert (enables server-side identity resolution)",
    )
    pric.add_argument("--match-rule-id", default=None, help="Use a pre-configured table identity rule by ID")
    pric.add_argument("--match-column", default=None, help="Column key to match on (when no rule-id)")
    pric.add_argument(
        "--match-strategy",
        choices=_MATCH_STRATEGIES,
        default=None,
        help="Normalisation strategy for the match column",
    )
    pric.add_argument(
        "--merge-mode",
        choices=_MERGE_MODES,
        default=None,
        help="Cell merge strategy when upserting (default: fill_empty)",
    )
    pric.set_defaults(trim_headers=True, empty_as_null=True, skip_empty_rows=True)
    pric.set_defaults(check_company_blacklist=True, check_email_blacklist=False, use_async=True)
    add_api_common_arguments(pric)
    pric.set_defaults(func=handlers["import_csv"])

    prdedupe = rows_sub.add_parser("dedupe", help="Preview or remove duplicate rows by column value")
    prdedupe.add_argument("--table-id", required=True)
    prdedupe.add_argument("--column-key", required=True, help="Column to deduplicate on")
    prdedupe.add_argument(
        "--stats-only",
        "--dry-run",
        dest="stats_only",
        action="store_true",
        help="Print deduplication preview without deleting rows",
    )
    add_api_common_arguments(prdedupe)
    prdedupe.set_defaults(func=handlers["dedupe"])

    prschema = rows_sub.add_parser("schema", help="Print row payload schemas for add/import helpers")
    prschema.add_argument("--type", choices=["all", *row_schema_types], default="all")
    prschema.add_argument("--out-file", help="Write schema JSON to file")
    add_output_formatting_arguments(prschema)
    prschema.set_defaults(func=handlers["schema"])

    pris = rows_sub.add_parser("import-status", help="Get or watch async CSV import status")
    pris.add_argument("--table-id", required=True)
    pris.add_argument("--task-id", required=True)
    pris.add_argument("--wait", action="store_true", help="Poll until completed/failed")
    pris.add_argument("--poll-interval", type=int, default=2, help="Polling interval seconds for --wait")
    pris.add_argument("--wait-timeout", type=int, default=0, help="Max seconds to wait (0 = no timeout)")
    add_api_common_arguments(pris)
    pris.set_defaults(func=handlers["import_status"])

    priv = rows_sub.add_parser("import-verify", help="Verify imported rows for task_id assertions")
    priv.add_argument("--table-id", required=True)
    priv.add_argument("--task-id", required=True)
    priv.add_argument("--expected-rows", type=int, help="Assert imported row count equals this value")
    priv.add_argument(
        "--require-columns",
        action="append",
        help="Assert required column keys exist (comma-separated; repeatable)",
    )
    priv.add_argument(
        "--duplicate-key",
        action="append",
        help="Assert no duplicates on this identity key (comma-separated; repeatable)",
    )
    priv.add_argument(
        "--require-non-empty",
        action="append",
        help="Assert non-empty ratio by key (key:ratio, comma-separated; repeatable)",
    )
    add_api_common_arguments(priv)
    priv.set_defaults(func=handlers["import_verify"])

    prir = rows_sub.add_parser("import-rollback", help="Rollback rows created by import task_id")
    prir.add_argument("--table-id", required=True)
    prir.add_argument("--task-id", required=True)
    prir.add_argument("--dry-run", action="store_true", help="Preview rollback scope without deleting rows")
    add_api_common_arguments(prir)
    prir.set_defaults(func=handlers["import_rollback"])

    prd = rows_sub.add_parser("delete", help="Delete a single row")
    prd.add_argument("--table-id", required=True)
    prd.add_argument("--row-id", required=True)
    add_api_common_arguments(prd)
    prd.set_defaults(func=handlers["delete"])
