from __future__ import annotations
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import StreamingResponse, HTMLResponse, RedirectResponse, FileResponse

import math
import hashlib
import pandas as pd
from typing import Any, Dict, List, Optional, Tuple
import io
import os
import zipfile
import re
import threading
import time
import json
from decimal import Decimal, ROUND_HALF_UP
import logging
import shutil
import asyncio
from dotenv import load_dotenv
from fastapi import FastAPI, UploadFile, File, HTTPException, Request, status, Response, BackgroundTasks
from starlette.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from datetime import date, datetime, timedelta, timezone

from settings import load_magento_config, load_db_config, load_attribute_registry_codes, load_attribute_registry_options
from normalize import (
    normalize_plytix_df,
    canonicalize_plytix_columns,
    _extract_child_skus,
    _case_insensitive_col,
    _is_empty as _normalize_is_empty,
)
from db.pipeline import ingest_and_normalize_from_db
from db.session import get_session
from db.models import AttributeDiscovery, PlytixFileQueue
from db.repositories import SqlAlchemyIngestRepository
from db.magento_repositories import (
    SqlAlchemyAttributeRegistryRepository,
    SqlAlchemyAttributeSetAttributesRepository,
    SqlAlchemyAttributeSetRegistryRepository,
    SqlAlchemyBaselineSyncRunRepository,
    SqlAlchemyCategoryRegistryRepository,
    SqlAlchemyMagentoAttributeCacheRepository,
    SqlAlchemyMagentoCategoryCacheRepository,
    SqlAlchemyMagentoCatalogStateRepository,
    SqlAlchemyMagentoConnectionRepository,
    SqlAlchemyMagentoMediaMapRepository,
    SqlAlchemyMagentoProductOverrideRepository,
    SqlAlchemyMagentoProductVersionRepository,
    SqlAlchemyMagentoStoreRegistryRepository,
    SqlAlchemySourceRegistryRepository,
    SqlAlchemyMagentoSyncNotificationRepository,
    SqlAlchemyMagentoSyncQueueRepository,
    SqlAlchemyMagentoSyncRepository,
    SqlAlchemyMagentoSyncPendingRepository,
    SqlAlchemyMagentoSyncStateRepository,
    SqlAlchemyPlytixFeedEventRepository,
)
from db.overrides import apply_overrides, build_overrides_from_df
from db.services import build_dataframe_from_snapshots, expand_sku_list_with_children
from db.source_imports import (
    import_magento_source_dataframe,
    import_plytix_source_dataframe,
    import_supplemental_attribute_dataframe,
)
from db.attribute_audit import build_attribute_audit
from db.attribute_mapping import build_attribute_action_plan, build_attribute_mapping_review
from db.attribute_mapping import export_attribute_mappings, import_attribute_mapping_dataframe
from db.channel_aliases import export_channel_attribute_aliases, import_channel_attribute_alias_dataframe
from db.channel_assignments import (
    assign_skus,
    import_channel_assignment_dataframe,
    list_assignments,
    remove_skus,
)
from db.channel_exports import (
    CHANNEL_CODES,
    build_channel_attribute_preview,
    build_channel_product_payloads,
)
from db.catalog_operations import (
    build_catalog_operations_registry,
    build_catalog_operations_summary,
)
from db.catalog_normalization_service import (
    build_normalization_overview,
    delete_collection_commerce_profile,
    delete_normalization_profile,
    delete_normalization_rule,
    delete_value_alias,
    inspect_projection,
    list_collection_commerce_profiles,
    list_catalog_policies,
    list_normalization_profiles,
    list_normalization_rules,
    list_projection_rows,
    list_review_queue,
    list_value_aliases,
    projection_status,
    rebuild_projection,
    resolve_review_item,
    upsert_collection_commerce_profile,
    upsert_catalog_policy,
    upsert_normalization_profile,
    upsert_normalization_rule,
    upsert_value_alias,
)
from db.variation_builder import commit_variation_plan, preview_variation_plan, suggest_variation_plan
from db.master_overrides import (
    export_master_overrides,
    import_master_override_dataframe,
    set_master_override,
)
from db.master_catalog import (
    build_master_catalog_validation,
    flatten_master_catalog_validation,
    import_master_sku_dataframe,
    reprocess_master_sku_raw_payloads,
)
from db.location_experience import collection_location_context
from db.location_registry import active_location_directory
from db.plytix_attributes import (
    export_plytix_attribute_catalog,
    import_plytix_attribute_catalog_dataframe,
    sync_catalog_to_attribute_def,
)
from db.url_shortener import shorten_image_url, resolve_slug, shorten_image_urls_in_df
import io
import zipfile
from datetime import datetime

import pandas as pd
from fastapi import File, UploadFile, HTTPException, Body, Query
from fastapi.responses import StreamingResponse, HTMLResponse, RedirectResponse, FileResponse

from db.schemas import MagentoConnectionUpsert, MagentoSyncItemCreate, MagentoSyncJobCreate
from magento.oauth_client import MagentoOAuthClient, build_magento_oauth_kwargs
from magento.magento_api import MagentoRestClient
from magento.tracing_client import TracingOAuthClient
from magento.sync_service import MagentoSyncService
from magento.compare import flatten_magento_product, flatten_pie_product, build_comparison
from magento.media_cleanup import cleanup_orphaned_product_media

load_dotenv()
#router = APIRouter(prefix="/api/plytix", tags=["Plytix"])
# FastAPI application setup
app = FastAPI(
    openapi_url=None  # Completely disable OpenAPI schema
)
# ----------------------------
# FastAPI endpoint
# ----------------------------
allowed_origins = [
    "https://plytixdash.dev.piesol.com",
    "https://plytixmagedash.dev.piesol.com",
    "http://dev.piesol.com",
    "http://localhost:3000",
    "http://localhost:8787",
    "http://127.0.0.1:8787",
]

def _extract_error_message_short(err: Any) -> str:
    """Extract a short, user-friendly message from Magento/sync error. Drops trace and long text."""
    if err is None or not str(err).strip():
        return ""
    s = str(err).strip()
    # Try JSON parse
    try:
        d = json.loads(s)
        if isinstance(d, dict) and d.get("message"):
            return str(d["message"])[:300]
    except (json.JSONDecodeError, TypeError):
        pass
    # Try Python repr dict: {'message': '...', 'trace': '...'}
    m = re.search(r"""['"]message['"]\s*:\s*['"]([^'"]*)['"]""", s)
    if m:
        return m.group(1)[:300]
    return s[:300] if len(s) > 300 else s


def _sanitize_for_json(obj: object) -> object:
    """Recursively convert NaN/Inf to None so JSON serialization succeeds."""
    if obj is None:
        return None
    if isinstance(obj, float):
        if math.isnan(obj) or math.isinf(obj):
            return None
        return obj
    if isinstance(obj, (int, str, bool)):
        return obj
    if isinstance(obj, Decimal):
        try:
            f = float(obj)
            return None if (math.isnan(f) or math.isinf(f)) else f
        except (ValueError, OverflowError):
            return None
    if isinstance(obj, dict):
        return {k: _sanitize_for_json(v) for k, v in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [_sanitize_for_json(v) for v in obj]
    if hasattr(obj, "isoformat"):  # datetime
        return obj.isoformat()
    return obj


# Function to check if a domain matches the wildcard subdomain pattern
def is_amplify_preview_url(origin: str) -> bool:
    # Regex pattern for matching any subdomain of amplifyapp.com
    #amplify_preview_pattern = r"^https://([a-zA-Z0-9\-]+)\.([a-zA-Z0-9\-]+)\.amplifyapp\.com$"
    #return bool(re.match(amplify_preview_pattern, origin))
    return origin.endswith(".amplifyapp.com")

# Combine static and dynamic origins
def get_allowed_origins():
    # You can also dynamically fetch preview URLs if needed (e.g., from environment variables or API)
    # For now, we're assuming you've manually defined them in `allowed_origins` above.
    dynamic_origins = [url for url in os.getenv("   ", "").split(",") if is_amplify_preview_url(url)]
    return allowed_origins + dynamic_origins

class DynamicCORSMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        origin = request.headers.get("origin")
        request_headers = request.headers.get("access-control-request-headers", "")

        if origin and (origin in get_allowed_origins() or is_amplify_preview_url(origin)):
            # Preflight request → return immediately
            if request.method == "OPTIONS":
                return Response(
                    status_code=204,
                    headers={
                        "Access-Control-Allow-Origin": origin,
                        "Access-Control-Allow-Credentials": "true",
                        "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
                        "Access-Control-Allow-Headers": request_headers or "Content-Type, Authorization",
                    },
                )

            # Normal request → add CORS headers after processing
            response: Response = await call_next(request)
            response.headers["Access-Control-Allow-Origin"] = origin
            response.headers["Access-Control-Allow-Credentials"] = "true"
            response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
            response.headers["Access-Control-Allow-Headers"] = request_headers or "Content-Type, Authorization"
            return response

        # Origin not allowed
        if request.method == "OPTIONS":
            return JSONResponse({"detail": "CORS origin not allowed"}, status_code=403)

        return await call_next(request)


app.add_middleware(DynamicCORSMiddleware)

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PLYTIX_UPLOAD_DIR = os.getenv(
    "PLYTIX_UPLOAD_DIR",
    os.path.abspath(os.path.join(BASE_DIR, "..", "plytix", "upload")),
)
PLYTIX_DOWNLOAD_DIR = os.getenv(
    "PLYTIX_DOWNLOAD_DIR",
    os.path.abspath(os.path.join(BASE_DIR, "..", "plytix", "transform", "download")),
)
PLYTIX_WORKER_INTERVAL_SECONDS = int(os.getenv("PLYTIX_WORKER_INTERVAL_SECONDS", "43200"))
PLYTIX_WORKER_POLL_SECONDS = int(os.getenv("PLYTIX_WORKER_POLL_SECONDS", "120"))
PLYTIX_WORKER_PROCESSING_TIMEOUT_SECONDS = int(
    os.getenv("PLYTIX_WORKER_PROCESSING_TIMEOUT_SECONDS", "1800")
)
PLYTIX_WORKER_ENABLED = os.getenv("PLYTIX_WORKER_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
MAGENTO_SYNC_WORKER_ENABLED = os.getenv("MAGENTO_SYNC_WORKER_ENABLED", "false").strip().lower() in {
    "1",
    "true",
    "yes",
    "on",
}
CHANNEL_JOB_WORKER_ENABLED = os.getenv("CHANNEL_JOB_WORKER_ENABLED", "false").strip().lower() in {
    "1",
    "true",
    "yes",
    "on",
}
# Default false: production should run `python -m app.jobs.refresh_channel_readiness`
# as a dedicated systemd unit. Enabling this embeds the worker in every uvicorn process.
CHANNEL_READINESS_WORKER_ENABLED = os.getenv("CHANNEL_READINESS_WORKER_ENABLED", "false").strip().lower() in {
    "1",
    "true",
    "yes",
    "on",
}
CHANNEL_READINESS_POLL_SECONDS = int(os.getenv("CHANNEL_READINESS_POLL_SECONDS", "30"))
PLYTIX_ARCHIVE_DIR = os.getenv(
    "PLYTIX_ARCHIVE_DIR",
    os.path.abspath(os.path.join(BASE_DIR, "..", "plytix", "archive")),
)
PLYTIX_WORKER_LOCK_PATH = os.getenv(
    "PLYTIX_WORKER_LOCK_PATH",
    os.path.abspath(os.path.join(BASE_DIR, ".plytix_worker.lock")),
)
PLYTIX_WORKER_LOG_PATH = os.getenv(
    "PLYTIX_WORKER_LOG_PATH",
    os.path.abspath(os.path.join(BASE_DIR, "plytix_worker.log")),
)
BASE_IMAGE_URL = os.getenv("BASE_IMAGE_URL", "").rstrip("/")

_processed_files_lock = threading.Lock()
_processed_files: set[str] = set()

formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
file_handler = logging.FileHandler(PLYTIX_WORKER_LOG_PATH)
file_handler.setFormatter(formatter)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)

# Configure root logger so all module loggers (db.url_shortener, etc.) emit to the same handlers
root_logger = logging.getLogger()
if not root_logger.handlers:
    root_logger.setLevel(logging.INFO)
    root_logger.addHandler(file_handler)
    root_logger.addHandler(console_handler)

logger = logging.getLogger("plytix-worker")
if not logger.handlers:
    logger.setLevel(logging.INFO)
    logger.addHandler(file_handler)
    logger.addHandler(console_handler)


def _is_safe_filename(name: str) -> bool:
    return bool(name) and os.path.basename(name) == name


def _parse_list_env(value: Optional[str]) -> List[str]:
    if not value:
        return []
    try:
        parsed = json.loads(value)
        if isinstance(parsed, list):
            return [str(item) for item in parsed]
    except json.JSONDecodeError:
        pass
    return [part.strip() for part in value.split(",") if part.strip()]


def _get_upload_targets() -> List[Tuple[str, str]]:
    upload_dirs = _parse_list_env(os.getenv("PLYTIX_UPLOAD_DIRS"))
    labels = _parse_list_env(os.getenv("PLYTIX_UPLOAD_LABELS"))

    if not upload_dirs:
        upload_dirs = [PLYTIX_UPLOAD_DIR]
    if not labels or len(labels) != len(upload_dirs):
        labels = [os.path.basename(path.rstrip(os.sep)) or "default" for path in upload_dirs]
    else:
        matched = _match_labels_to_dirs(labels, upload_dirs)
        if matched:
            labels, upload_dirs = matched

    targets: List[Tuple[str, str]] = []
    for label, path in zip(labels, upload_dirs):
        if _is_safe_filename(label):
            targets.append((label, path))
    return targets


def _match_labels_to_dirs(
    labels: List[str],
    upload_dirs: List[str],
) -> Optional[Tuple[List[str], List[str]]]:
    normalized = {
        label.strip().lower(): label for label in labels if label.strip()
    }
    if len(normalized) != len(labels):
        return None

    dir_map: Dict[str, str] = {}
    for path in upload_dirs:
        base = os.path.basename(path.rstrip(os.sep)).lower()
        for key, original in normalized.items():
            if key and key in base:
                if key in dir_map:
                    return None
                dir_map[key] = path
                break

    if len(dir_map) != len(labels):
        return None

    ordered_labels = []
    ordered_dirs = []
    for label in labels:
        key = label.strip().lower()
        ordered_labels.append(label)
        ordered_dirs.append(dir_map[key])
    return ordered_labels, ordered_dirs


def _iter_upload_csv_files() -> List[Tuple[str, str, str]]:
    entries: List[Tuple[str, str, str]] = []
    for label, upload_dir in _get_upload_targets():
        if not os.path.isdir(upload_dir):
            logger.warning("upload dir missing: %s", upload_dir)
            continue
        for filename in os.listdir(upload_dir):
            if filename.lower().endswith(".csv"):
                full_path = os.path.join(upload_dir, filename)
                entries.append((label, full_path, filename))
    return entries


def _get_upload_dir_for_label(label: str) -> Optional[str]:
    for candidate_label, upload_dir in _get_upload_targets():
        if candidate_label == label:
            return upload_dir
    return None


def _get_latest_csv_in_dir(path: str) -> Optional[str]:
    if not os.path.isdir(path):
        return None
    csv_files = [
        os.path.join(path, name)
        for name in os.listdir(path)
        if name.lower().endswith(".csv")
    ]
    if not csv_files:
        return None
    return max(csv_files, key=os.path.getmtime)


def _shorten_image_urls_if_enabled(df: pd.DataFrame) -> pd.DataFrame:
    from settings import shorten_image_urls_enabled
    if not shorten_image_urls_enabled():
        logger.debug("shorten_image_urls: disabled (SHORTEN_IMAGE_URLS_ENABLED not set)")
        return df
    logger.info("shorten_image_urls: entered, BASE_IMAGE_URL=%r, rows=%d", BASE_IMAGE_URL, len(df))
    if not BASE_IMAGE_URL:
        logger.info("shorten_image_urls: skipping — BASE_IMAGE_URL is not set")
        return df
    with get_session() as session:
        repo = SqlAlchemyIngestRepository(session)
        result = shorten_image_urls_in_df(df, repo)
    logger.info("shorten_image_urls: done, rows=%d", len(result))
    return result


def _apply_overrides_if_enabled(df: pd.DataFrame, nooverwrite: bool = False) -> pd.DataFrame:
    if nooverwrite:
        return df
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        return df
    skus = [str(v).strip() for v in df.get("sku", []) if str(v).strip()]
    if not skus:
        return df
    with get_session() as session:
        repo = SqlAlchemyIngestRepository(session)
        overrides = repo.load_overrides_for_skus(skus)
    return apply_overrides(df, overrides)


def _enqueue_new_files() -> int:
    inserted = 0
    entries = _iter_upload_csv_files()
    if not entries:
        return inserted
    with get_session() as session:
        for label, input_path, filename in entries:
            try:
                stat = os.stat(input_path)
            except OSError:
                continue
            mtime = (
                Decimal(str(stat.st_mtime))
                .quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
            )
            size = int(stat.st_size)
            mtime_floor = mtime - Decimal("0.0005")
            mtime_ceil = mtime + Decimal("0.0005")
            exists = (
                session.query(PlytixFileQueue.id)
                .filter(PlytixFileQueue.file_path == input_path)
                .filter(PlytixFileQueue.file_mtime.between(mtime_floor, mtime_ceil))
                .filter(
                    (PlytixFileQueue.file_size == size)
                    | (PlytixFileQueue.file_size.is_(None))
                )
                .first()
            )
            if exists:
                continue
            session.add(
                PlytixFileQueue(
                    label=label,
                    file_path=input_path,
                    file_name=filename,
                    file_mtime=mtime,
                    file_size=size,
                    status="new",
                )
            )
            inserted += 1
    return inserted


def _claim_next_file() -> Optional[dict]:
    with get_session() as session:
        timeout_cutoff = datetime.utcnow() - timedelta(
            seconds=PLYTIX_WORKER_PROCESSING_TIMEOUT_SECONDS
        )
        stale = (
            session.query(PlytixFileQueue)
            .filter(PlytixFileQueue.status == "processing")
            .filter(PlytixFileQueue.processing_started_at.isnot(None))
            .filter(PlytixFileQueue.processing_started_at < timeout_cutoff)
            .all()
        )
        for row in stale:
            row.status = "new"
            row.last_error = "processing timeout (requeued)"
            row.processing_started_at = None
            row.processed_at = None
            session.add(row)

        row = (
            session.query(PlytixFileQueue)
            .filter(PlytixFileQueue.status == "new")
            .order_by(PlytixFileQueue.discovered_at.asc())
            .first()
        )
        if not row:
            return None
        row.status = "processing"
        row.processing_started_at = datetime.utcnow()
        session.add(row)
        session.flush()
        session.refresh(row)
        return {
            "id": row.id,
            "label": row.label,
            "file_path": row.file_path,
            "file_name": row.file_name,
            "file_mtime": row.file_mtime,
            "file_size": row.file_size,
        }


def _finalize_file(row_id: int, *, status: str, error: Optional[str] = None) -> None:
    with get_session() as session:
        row = session.query(PlytixFileQueue).filter(PlytixFileQueue.id == row_id).first()
        if not row:
            return
        row.status = status
        row.last_error = error
        row.processing_started_at = row.processing_started_at or datetime.utcnow()
        row.processed_at = datetime.utcnow()
        session.add(row)


def _normalize_csv_file(input_path: str, output_path: str) -> None:
    with open(input_path, "rb") as handle:
        content = handle.read()

    df = pd.read_csv(
        io.BytesIO(content),
        dtype=str,
        keep_default_na=False,
    )
    logger.info("_normalize_csv_file: read csv rows=%d input=%s", len(df), input_path)
    df = canonicalize_plytix_columns(df)
    df = _apply_overrides_if_enabled(df)
    cfg = load_magento_config()
    registry_codes = load_attribute_registry_codes(load_db_config().attribute_registry_path)
    registry_options = load_attribute_registry_options(load_db_config().attribute_registry_path)
    allowed_codes = registry_codes or None
    normalized_df, quarantine_df = normalize_plytix_df(
        df,
        cfg,
        allowed_attribute_codes=allowed_codes,
        allowed_attribute_options=registry_options or None,
        enforce_required_name=False,
    )
    logger.info("_normalize_csv_file: normalization done normalized=%d quarantine=%d", len(normalized_df), len(quarantine_df))
    try:
        normalized_df = _shorten_image_urls_if_enabled(normalized_df)
    except Exception:
        logger.exception("_normalize_csv_file: _shorten_image_urls_if_enabled failed — writing CSV without shortening")
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    normalized_df.to_csv(output_path, index=False)
    quarantine_path = output_path.replace(".csv", "_quarantine.csv")
    quarantine_df.to_csv(quarantine_path, index=False)
    logger.info("_normalize_csv_file: wrote output=%s quarantine=%s", output_path, quarantine_path)

    db_cfg = load_db_config()
    if db_cfg.enabled:
        logger.info("_normalize_csv_file: starting DB ingest path")
        db_normalized, db_quarantine = ingest_and_normalize_from_db(
            df,
            file_name=os.path.basename(input_path),
            file_bytes=content,
            cfg=cfg,
        )
        db_output_path = output_path.replace(".csv", f"{db_cfg.output_suffix}.csv")
        db_normalized.to_csv(db_output_path, index=False)
        db_quarantine_path = output_path.replace(".csv", f"{db_cfg.output_suffix}_quarantine.csv")
        db_quarantine.to_csv(db_quarantine_path, index=False)

        # Trigger deletion detection for all active connections
        _run_deletion_detection_after_ingest()


def _run_deletion_detection_after_ingest() -> None:
    """After ingest completes, detect SKUs missing from Magento for all active connections."""
    try:
        from db.session import get_session
        from db.models import MagentoConnection, IngestRun
        from db.magento_repositories import SqlAlchemyMagentoConnectionRepository
        from app.jobs.magento_deletion_detector import run_detection
        from sqlalchemy import select as _select

        with get_session() as session:
            # Get the latest completed ingest run (status = "success")
            latest_run = (
                session.execute(
                    _select(IngestRun)
                    .where(IngestRun.status == "success")
                    .order_by(IngestRun.id.desc())
                    .limit(1)
                )
                .scalar_one_or_none()
            )
            if not latest_run:
                logger.info("deletion_detector: no completed ingest run found, skipping")
                return
            ingest_run_id = latest_run.id

            # Get all active connections
            conn_repo = SqlAlchemyMagentoConnectionRepository(session)
            conns = conn_repo.list_connections()
            active_conns = [c for c in conns if c.status == "active"]

        for conn in active_conns:
            try:
                flagged = run_detection(conn.id, ingest_run_id)
                logger.info(
                    "deletion_detector: connection=%d ingest_run=%d flagged=%d",
                    conn.id, ingest_run_id, flagged,
                )
            except Exception as exc:
                logger.error(
                    "deletion_detector error connection=%d ingest_run=%d: %s",
                    conn.id, ingest_run_id, exc,
                )
    except Exception as exc:
        logger.exception("deletion_detector: unexpected error: %s", exc)


def _enqueue_magento_sync_for_label(label: str) -> None:
    """After normalization completes, enqueue Magento sync for all active connections."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        return
    try:
        from db.session import get_session
        from db.magento_repositories import (
            SqlAlchemyMagentoConnectionRepository,
            SqlAlchemyMagentoSyncQueueRepository,
            SqlAlchemyPlytixFeedEventRepository,
        )
        with get_session() as session:
            conn_repo = SqlAlchemyMagentoConnectionRepository(session)
            queue_repo = SqlAlchemyMagentoSyncQueueRepository(session)
            feed_repo = SqlAlchemyPlytixFeedEventRepository(session)
            conns = conn_repo.list_connections()
            ids = [c.id for c in conns if c.status == "active"]
            if not ids:
                logger.info("enqueue_magento_sync: no active connections, skipping")
                return
            from magento.sync_defaults import default_magento_queue_options
            from settings import load_email_config
            notify_email = load_email_config().to_default or None
            feed_repo.create(label, "normalized_ready", {"connection_ids": ids})
            for cid in ids:
                opts = default_magento_queue_options()
                if notify_email:
                    opts["notify_email"] = notify_email
                queue_repo.enqueue(cid, label, options=opts)
            session.commit()
        logger.info("enqueue_magento_sync: label=%s queued for connections=%s notify=%s", label, ids, notify_email)
    except Exception as exc:
        logger.exception("enqueue_magento_sync failed for label=%s: %s", label, exc)


def _scan_and_process_uploads() -> None:
    targets = _get_upload_targets()
    logger.info("scan start: targets=%s", targets)
    new_files = _enqueue_new_files()
    processed = 0
    failed = 0

    row = _claim_next_file()
    if not row:
        logger.info("scan done: new=%s processed=%s failed=%s", new_files, processed, failed)
        return

    try:
        date_dir = datetime.now().strftime("%Y%m%d")
        output_dir = os.path.join(PLYTIX_DOWNLOAD_DIR, row["label"])
        output_path = os.path.join(output_dir, f"{date_dir}.csv")
        exists = os.path.isfile(row["file_path"])
        logger.info(
            "normalize: input=%s exists=%s size=%s mtime=%s output=%s",
            row["file_path"],
            exists,
            row["file_size"],
            row["file_mtime"],
            output_path,
        )
        if not exists:
            _finalize_file(row["id"], status="failed", error="file not found")
            logger.error("failed: input=%s error=file not found", row["file_path"])
            return
        logger.info("normalize: starting _normalize_csv_file input=%s output=%s", row["file_path"], output_path)
        _normalize_csv_file(row["file_path"], output_path)
        logger.info("normalize: completed _normalize_csv_file output=%s", output_path)

        archive_dir = os.path.join(PLYTIX_ARCHIVE_DIR, row["label"], date_dir)
        os.makedirs(archive_dir, exist_ok=True)
        archive_path = os.path.join(archive_dir, row["file_name"])
        shutil.copy2(row["file_path"], archive_path)
        processed += 1
        _finalize_file(row["id"], status="done")
        logger.info("archived: from=%s to=%s", row["file_path"], archive_path)

        # Enqueue Magento sync only when MAGENTO_SYNC_ON_FEED_UPLOAD=true (else use scheduled/on-demand)
        from settings import load_magento_sync_trigger_config
        if load_magento_sync_trigger_config().sync_on_feed_upload:
            _enqueue_magento_sync_for_label(row["label"])

    except Exception as exc:
        failed += 1
        _finalize_file(row["id"], status="failed", error=str(exc))
        logger.exception("failed: input=%s error=%s", row["file_path"], exc)

    logger.info("scan done: new=%s processed=%s failed=%s", new_files, processed, failed)


def _acquire_worker_lock() -> Optional[int]:
    try:
        return os.open(PLYTIX_WORKER_LOCK_PATH, os.O_CREAT | os.O_EXCL | os.O_RDWR)
    except FileExistsError:
        return None


def _release_worker_lock(fd: Optional[int]) -> None:
    if fd is None:
        return
    try:
        os.close(fd)
    finally:
        try:
            os.unlink(PLYTIX_WORKER_LOCK_PATH)
        except OSError:
            pass


def run_worker_once() -> None:
    lock_fd = _acquire_worker_lock()
    if lock_fd is None:
        logger.info("lock exists; skipping run")
        return
    try:
        _scan_and_process_uploads()
    finally:
        _release_worker_lock(lock_fd)


def _worker_loop() -> None:
    while True:
        try:
            run_worker_once()
        except Exception as exc:
            logger.exception("unexpected error: %s", exc)
        time.sleep(PLYTIX_WORKER_POLL_SECONDS)


@app.on_event("startup")
async def start_plytix_worker() -> None:
    if not PLYTIX_WORKER_ENABLED:
        logger.info("plytix-worker: disabled (set PLYTIX_WORKER_ENABLED=true to enable)")
        return
    worker = threading.Thread(target=_worker_loop, name="plytix-worker", daemon=True)
    worker.start()


_last_scheduled_sync_date: Optional[date] = None


def _maybe_run_scheduled_sync() -> bool:
    """If scheduled sync is enabled and it's time, enqueue. Returns True if enqueued.
    Uses DB to check if today's scheduled sync was already enqueued (survives app restart)."""
    global _last_scheduled_sync_date
    from db.session import get_session
    from db.magento_repositories import SqlAlchemyMagentoSyncQueueRepository
    from settings import load_magento_sync_trigger_config
    cfg = load_magento_sync_trigger_config()
    if not cfg.scheduled_sync_enabled:
        return False
    try:
        parts = cfg.scheduled_sync_time_utc.split(":")
        h, m = int(parts[0]), int(parts[1]) if len(parts) > 1 else 0
    except (ValueError, IndexError):
        logger.warning("magento-worker: invalid MAGENTO_SCHEDULED_SYNC_TIME_UTC=%r", cfg.scheduled_sync_time_utc)
        return False
    now = datetime.now(timezone.utc)
    today = now.date()
    scheduled_dt = now.replace(hour=h, minute=m, second=0, microsecond=0)
    if now < scheduled_dt:
        return False
    if _last_scheduled_sync_date == today:
        return False
    # Persisted check: avoid re-enqueueing on app restart (in-memory _last_scheduled_sync_date resets)
    with get_session() as session:
        queue_repo = SqlAlchemyMagentoSyncQueueRepository(session)
        if queue_repo.has_scheduled_sync_for_date(today):
            return False
    try:
        from app.jobs.magento_scheduled_sync import run_scheduled_sync
        result = run_scheduled_sync()
        if result.get("status") == "ok" and result.get("queued", 0) > 0:
            _last_scheduled_sync_date = today
            logger.info("magento-worker: scheduled sync enqueued at %s, label=%s queued=%s",
                       now.isoformat(), result.get("label"), result.get("queued"))
            return True
    except Exception as exc:
        logger.exception("magento-worker: scheduled sync failed: %s", exc)
    return False


def _magento_sync_worker_loop() -> None:
    from settings import load_db_config
    if not load_db_config().enabled:
        logger.info("magento-worker: DB disabled, not starting")
        return
    from app.jobs.magento_sync_worker import run_one
    logger.info("magento-worker: started, idle poll=60s")
    while True:
        try:
            had_work = run_one()
        except Exception as exc:
            logger.exception("magento-worker: unexpected error: %s", exc)
            had_work = False
        if not had_work:
            _maybe_run_scheduled_sync()
            logger.debug("magento-worker: queue empty, sleeping 60s")
            time.sleep(60)


@app.on_event("startup")
async def start_magento_sync_worker() -> None:
    if not MAGENTO_SYNC_WORKER_ENABLED:
        logger.info("magento-worker: disabled (set MAGENTO_SYNC_WORKER_ENABLED=true to enable)")
        return
    worker = threading.Thread(target=_magento_sync_worker_loop, name="magento-worker", daemon=True)
    worker.start()


def _channel_job_worker_loop() -> None:
    from settings import load_db_config

    if not load_db_config().enabled:
        logger.info("channel-worker: DB disabled, not starting")
        return
    from app.jobs.channel_job_worker import run_one

    poll_seconds = int(os.getenv("CHANNEL_JOB_WORKER_POLL_SECONDS", "15"))
    logger.info("channel-worker: started, idle poll=%ss", poll_seconds)
    while True:
        try:
            had_work = run_one()
        except Exception as exc:
            logger.exception("channel-worker: unexpected error: %s", exc)
            had_work = False
        if not had_work:
            time.sleep(poll_seconds)


@app.on_event("startup")
async def start_channel_job_worker() -> None:
    if not CHANNEL_JOB_WORKER_ENABLED:
        logger.info("channel-worker: disabled (set CHANNEL_JOB_WORKER_ENABLED=true to enable)")
        return
    worker = threading.Thread(target=_channel_job_worker_loop, name="channel-worker", daemon=True)
    worker.start()


def _readiness_cache_worker_loop() -> None:
    from settings import load_db_config

    if not load_db_config().enabled:
        logger.info("readiness-worker: DB disabled, not starting")
        return
    from db.channel_readiness_cache import enqueue_stale_readiness_refreshes, process_one_readiness_refresh

    worker_id = f"readiness-worker-{os.getpid()}"
    logger.info(
        "readiness-worker: started poll=%ss refresh_hours=%s",
        CHANNEL_READINESS_POLL_SECONDS,
        os.getenv("CHANNEL_READINESS_REFRESH_HOURS", "4"),
    )
    while True:
        try:
            with get_session() as session:
                enqueue_stale_readiness_refreshes(session)
                process_one_readiness_refresh(session, worker_id=worker_id)
                session.commit()
        except Exception as exc:
            logger.exception("readiness-worker: pass failed: %s", exc)
        time.sleep(CHANNEL_READINESS_POLL_SECONDS)


@app.on_event("startup")
async def start_readiness_cache_worker() -> None:
    if not CHANNEL_READINESS_WORKER_ENABLED:
        logger.info("readiness-worker: disabled (set CHANNEL_READINESS_WORKER_ENABLED=true to enable)")
        return
    worker = threading.Thread(target=_readiness_cache_worker_loop, name="readiness-worker", daemon=True)
    worker.start()




@app.get("/")
async def root():
    return {"message": "Pie PLANS Server"}

@app.get("/api/")
async def root():
    return {"message": "Pie PLANS API Server"}
@app.post("/api/plytix/normalize-magento")
async def normalize_magento_csv(file: UploadFile = File(...)):
    if not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")

    content = await file.read()

    try:
        df = pd.read_csv(
            io.BytesIO(content),
            dtype=str,
            keep_default_na=False,
        )
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {e}")

    df = canonicalize_plytix_columns(df)

    try:
        cfg = load_magento_config()
        registry_codes = load_attribute_registry_codes(load_db_config().attribute_registry_path)
        registry_options = load_attribute_registry_options(load_db_config().attribute_registry_path)
        allowed_codes = registry_codes or None
        df = _apply_overrides_if_enabled(df)
        normalized_df, quarantine_df = normalize_plytix_df(
            df,
            cfg,
            allowed_attribute_codes=allowed_codes,
            allowed_attribute_options=registry_options or None,
            enforce_required_name=False,
        )
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))

    normalized_df = _shorten_image_urls_if_enabled(normalized_df)

    magento_buf = io.StringIO()
    quarantine_buf = io.StringIO()
    normalized_df.to_csv(magento_buf, index=False)
    quarantine_df.to_csv(quarantine_buf, index=False)
    stats = {
        "input_rows": len(df),
        "normalized_rows": len(normalized_df),
        "quarantine_rows": len(quarantine_df),
        "seed_children_col": cfg.seed_children_col,
        "axis_col": cfg.axis_col,
        "labels_col": cfg.labels_col,
    }
    stats_buf = io.StringIO()
    stats_buf.write("\n".join([f"{k}={v}" for k, v in stats.items()]))

    zip_buf = io.BytesIO()
    with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("magento_products.csv", magento_buf.getvalue())
        z.writestr("quarantine.csv", quarantine_buf.getvalue())
        z.writestr("stats.txt", stats_buf.getvalue())

        db_cfg = load_db_config()
        if db_cfg.enabled:
            db_normalized, db_quarantine = ingest_and_normalize_from_db(
                df,
                file_name=file.filename,
                file_bytes=content,
                cfg=cfg,
            )
            db_magento_buf = io.StringIO()
            db_quarantine_buf = io.StringIO()
            db_normalized.to_csv(db_magento_buf, index=False)
            db_quarantine.to_csv(db_quarantine_buf, index=False)
            z.writestr(f"magento_products{db_cfg.output_suffix}.csv", db_magento_buf.getvalue())
            z.writestr(f"quarantine{db_cfg.output_suffix}.csv", db_quarantine_buf.getvalue())

        _run_deletion_detection_after_ingest()

    zip_buf.seek(0)
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
    return StreamingResponse(
        zip_buf,
        media_type="application/zip",
        headers={"Content-Disposition": f"attachment; filename=magento_normalized_{timestamp}.zip"},
    )


@app.get("/api/plytix/attributes/discovery")
async def download_attribute_discovery(status: str = "new"):
    with get_session() as session:
        query = session.query(AttributeDiscovery)
        if status:
            query = query.filter(AttributeDiscovery.status == status)
        rows = query.all()
        df = pd.DataFrame(
            [
                {
                    "attribute_code": row.attribute_code,
                    "new_value": row.new_value,
                    "sku": row.sku,
                    "first_seen_at": row.first_seen_at,
                    "status": row.status,
                }
                for row in rows
            ]
        )
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=new_attributes.csv"},
    )


@app.post("/api/plytix/overrides/upload")
async def upload_overrides(
    file: Optional[UploadFile] = File(None),
    file_path: Optional[str] = None,
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB overrides are disabled")

    content: Optional[bytes] = None
    source = "upload"
    if file:
        if not file.filename.lower().endswith(".csv"):
            raise HTTPException(status_code=400, detail="Only CSV files are supported")
        content = await file.read()
        source = file.filename
    elif file_path:
        if not os.path.isfile(file_path):
            raise HTTPException(status_code=404, detail="file_path not found")
        with open(file_path, "rb") as handle:
            content = handle.read()
        source = os.path.basename(file_path)
    else:
        raise HTTPException(status_code=400, detail="file or file_path is required")

    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    df = canonicalize_plytix_columns(df)

    try:
        upserts, deletes = build_overrides_from_df(df, source=source)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))

    with get_session() as session:
        repo = SqlAlchemyIngestRepository(session)
        if deletes:
            repo.delete_overrides(deletes)
        if upserts:
            repo.upsert_overrides(upserts)

    return {
        "upserts": len(upserts),
        "deletes": len(deletes),
    }


@app.post("/api/source-imports/magento/products/csv")
async def import_magento_products_source_csv(
    file: UploadFile = File(...),
    connection_id: Optional[int] = Query(None),
):
    """Import raw Magento product CSV rows into a source snapshot table."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")

    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")

    with get_session() as session:
        repo = SqlAlchemyIngestRepository(session)
        ingest_id, stats = import_magento_source_dataframe(
            repo,
            df,
            file_name=file.filename,
            file_hash=hashlib.sha256(content).hexdigest(),
            connection_id=connection_id,
        )
        session.commit()

    return {"status": "ok", "source": "magento", "ingest_id": ingest_id, **stats}


@app.post("/api/source-imports/plytix/products/csv")
async def import_plytix_products_source_csv(file: UploadFile = File(...)):
    """Import raw Plytix product CSV rows into a source snapshot table."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")

    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")

    with get_session() as session:
        repo = SqlAlchemyIngestRepository(session)
        ingest_id, stats = import_plytix_source_dataframe(
            repo,
            df,
            file_name=file.filename,
            file_hash=hashlib.sha256(content).hexdigest(),
        )
        session.commit()

    return {"status": "ok", "source": "plytix", "ingest_id": ingest_id, **stats}


@app.post("/api/source-imports/plytix/products/pull")
async def pull_plytix_products(
    source_label: str = Query("plytix_api"),
    auto_link_skus: bool = Query(True),
):
    """Pull product data directly from Plytix API into Plytix source snapshots."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.plytix_pull import run_plytix_pull
    from db.channel_sku_mapping import auto_link_skus_after_pull

    with get_session() as session:
        result = run_plytix_pull(source_label=source_label)
        if result.get("status") != "ok":
            raise HTTPException(status_code=500, detail=result.get("error", "Plytix pull failed"))
        if auto_link_skus:
            result["auto_link"] = auto_link_skus_after_pull(
                session,
                channel_type="plytix",
                channel_code="plytix",
            )
            session.commit()
    return result


@app.post("/api/source-imports/plytix/attributes/pull")
async def pull_plytix_attributes(source_label: str = Query("plytix_api")):
    """Pull Plytix attribute definitions from API (token auth) into catalog + attribute_def."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.plytix_attributes_pull import run_plytix_attributes_pull

    result = run_plytix_attributes_pull(source_label=source_label)
    if result.get("status") != "ok":
        raise HTTPException(status_code=500, detail=result.get("error", "Plytix attribute pull failed"))
    return result


@app.post("/api/source-imports/plytix/attributes/csv")
async def import_plytix_attributes_csv(
    file: UploadFile = File(...),
    source_label: str = Query("plytix"),
    exclude_shopify: bool = Query(True, description="Skip Shopify connector attributes from the catalog"),
):
    """Import the full Plytix attribute catalog/list for cleanup planning."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")

    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")

    exclude_hints = ("shopify",) if exclude_shopify else ()
    with get_session() as session:
        stats = import_plytix_attribute_catalog_dataframe(
            session,
            df,
            source_label=source_label,
            exclude_destination_hints=exclude_hints,
        )
        attr_def_count = sync_catalog_to_attribute_def(session, source_label=source_label)
        session.commit()

    return {
        "status": "ok",
        "source": "plytix_attribute_catalog",
        "file": file.filename,
        "attribute_def_synced": attr_def_count,
        **stats,
    }


@app.get("/api/source-imports/plytix/attributes.csv")
async def plytix_attributes_csv():
    """Download the stored Plytix attribute catalog/list."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = export_plytix_attribute_catalog(session)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=plytix_attribute_catalog.csv"},
    )


@app.post("/api/shopify/attributes/refresh")
async def shopify_attributes_refresh(
    connection_id: Optional[int] = Query(None),
    shop_code: Optional[str] = Query(None),
):
    """Force-refresh Shopify product/variant metafield and option registry."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.shopify_attribute_sync import run_shopify_attribute_sync

    result = run_shopify_attribute_sync(connection_id=connection_id, shop_code=shop_code)
    if result.get("status") != "success":
        raise HTTPException(status_code=500, detail=result.get("error", "Shopify attribute sync failed"))
    return result


@app.get("/api/shopify/attributes.csv")
async def shopify_attributes_csv():
    """Download Shopify fields/metafields/options pulled from the store."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.shopify_repositories import SqlAlchemyShopifyAttributeRegistryRepository

    with get_session() as session:
        repo = SqlAlchemyShopifyAttributeRegistryRepository(session)
        rows = [
            {
                "shop_code": row.shop_code,
                "owner_type": row.owner_type,
                "attribute_code": row.attribute_code,
                "name": row.name or "",
                "namespace": row.namespace or "",
                "key": row.key or "",
                "data_type": row.data_type or "",
                "is_standard": row.is_standard,
                "fetched_at": row.fetched_at,
            }
            for row in repo.list_attributes()
        ]
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=shopify_attribute_registry.csv"},
    )


@app.post("/api/source-imports/products/supplemental-csvs")
async def import_product_supplemental_csvs(
    files: List[UploadFile] = File(...),
    source_label: str = Query("csv_upload"),
    sku_column: Optional[str] = Query(None),
):
    """
    Import arbitrary SKU CSVs as column-level source values.

    This is intentionally not normalized into Magento/Plytix yet; it preserves
    source file, original column name, normalized column key, and a light
    destination hint for Shopify/Magento/SEO-prefixed columns.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not files:
        raise HTTPException(status_code=400, detail="At least one CSV file is required")

    imported = []
    with get_session() as session:
        repo = SqlAlchemyIngestRepository(session)
        for file in files:
            if not file.filename or not file.filename.lower().endswith(".csv"):
                raise HTTPException(status_code=400, detail=f"Only CSV files are supported: {file.filename}")
            content = await file.read()
            try:
                df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
            except Exception as exc:
                raise HTTPException(status_code=400, detail=f"Failed to read {file.filename}: {exc}")
            ingest_id, stats = import_supplemental_attribute_dataframe(
                repo,
                df,
                file_name=file.filename,
                file_hash=hashlib.sha256(content).hexdigest(),
                source_label=source_label,
                sku_column=sku_column,
            )
            imported.append({"file_name": file.filename, "ingest_id": ingest_id, **stats})
        session.commit()

    return {
        "status": "ok",
        "source": "supplemental_attributes",
        "source_label": source_label,
        "files": imported,
    }


@app.get("/api/source-imports/attributes/audit")
async def source_attribute_audit(include_magento: bool = Query(True)):
    """Return grouped source attributes for cleanup/mapping review."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = build_attribute_audit(session, include_magento=include_magento)
    return {"status": "ok", "count": len(rows), "rows": rows}


@app.get("/api/source-imports/attributes/audit.csv")
async def source_attribute_audit_csv(include_magento: bool = Query(True)):
    """Download grouped source attributes as CSV for cleanup/mapping review."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = build_attribute_audit(session, include_magento=include_magento)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=source_attribute_audit.csv"},
    )


@app.get("/api/attribute-mapping/review")
async def attribute_mapping_review(include_magento: bool = Query(True)):
    """Return source-to-canonical attribute mapping candidates."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = build_attribute_mapping_review(session, include_magento=include_magento)
    return {"status": "ok", "count": len(rows), "rows": rows}


@app.get("/api/attribute-mapping/review.csv")
async def attribute_mapping_review_csv(include_magento: bool = Query(True)):
    """Download source-to-canonical attribute mapping candidates as CSV."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = build_attribute_mapping_review(session, include_magento=include_magento)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=attribute_mapping_review.csv"},
    )


@app.get("/api/attribute-mapping/action-plan")
async def attribute_action_plan(include_magento: bool = Query(True)):
    """Return per-system attribute cleanup/addition recommendations."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = build_attribute_action_plan(session, include_magento=include_magento)
    return {"status": "ok", "count": len(rows), "rows": rows}


@app.get("/api/attribute-mapping/action-plan.csv")
async def attribute_action_plan_csv(include_magento: bool = Query(True)):
    """Download per-system attribute cleanup/addition recommendations."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = build_attribute_action_plan(session, include_magento=include_magento)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=attribute_action_plan.csv"},
    )


@app.get("/api/attribute-mapping/mappings.csv")
async def attribute_mappings_csv():
    """Download approved/current source-to-canonical attribute mappings."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = export_attribute_mappings(session)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=attribute_mappings.csv"},
    )


@app.post("/api/attribute-mapping/mappings/csv")
async def import_attribute_mappings_csv(
    file: UploadFile = File(...),
    default_source_label: str = Query("mapping_csv"),
    accept_suggestions: bool = Query(False),
):
    """Upload approved source-to-canonical/channel attribute mappings from CSV."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    with get_session() as session:
        stats = import_attribute_mapping_dataframe(
            session,
            df,
            default_source_label=default_source_label,
            accept_suggestions=accept_suggestions,
        )
        session.commit()
    return {"status": "ok", "file": file.filename, **stats}


@app.get("/api/channel-aliases/mappings.csv")
async def channel_alias_mappings_csv():
    """Download approved canonical-to-channel attribute aliases."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = export_channel_attribute_aliases(session)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=channel_attribute_aliases.csv"},
    )


@app.post("/api/channel-aliases/mappings/csv")
async def import_channel_alias_mappings_csv(file: UploadFile = File(...)):
    """Upload approved canonical-to-channel attribute aliases from CSV."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    with get_session() as session:
        stats = import_channel_attribute_alias_dataframe(session, df)
        session.commit()
    return {"status": "ok", "file": file.filename, **stats}


# ----------------------------
# Remote channel attributes (connection-first mapper + convert-to-options)
# ----------------------------


@app.get("/api/channel-remote-attributes")
async def channel_remote_attributes_list(
    channel_type: str = Query(..., description="magento or shopify"),
    connection_id: int = Query(...),
    unmapped_only: bool = Query(False),
    search: Optional[str] = Query(None),
    limit: int = Query(2000, ge=1, le=10000),
):
    """Remote Magento/Shopify attributes with master mappings, type, and example values."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_remote_attributes import list_remote_channel_attributes

    try:
        with get_session() as session:
            payload = list_remote_channel_attributes(
                session,
                channel_type=channel_type,
                connection_id=connection_id,
                unmapped_only=unmapped_only,
                search=search,
                limit=limit,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", **payload}


@app.post("/api/channel-remote-attributes/map")
async def channel_remote_attributes_map(payload: Dict[str, Any] = Body(...)):
    """Add a remote→master mapping without removing existing targets (1→N)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_remote_attributes import add_remote_attribute_mapping

    try:
        with get_session() as session:
            result = add_remote_attribute_mapping(
                session,
                channel_type=str(payload.get("channel_type") or ""),
                channel_attribute_code=str(payload.get("channel_attribute_code") or ""),
                canonical_code=str(payload.get("canonical_code") or ""),
                data_type=payload.get("data_type"),
                sync_options_from_master=bool(payload.get("sync_options_from_master")),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return result


@app.post("/api/channel-remote-attributes/unmap")
async def channel_remote_attributes_unmap(payload: Dict[str, Any] = Body(...)):
    """Deactivate one remote→master mapping (other targets for the same master remain)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_remote_attributes import remove_remote_attribute_mapping

    try:
        with get_session() as session:
            result = remove_remote_attribute_mapping(
                session,
                channel_type=str(payload.get("channel_type") or ""),
                channel_attribute_code=str(payload.get("channel_attribute_code") or ""),
                canonical_code=str(payload.get("canonical_code") or ""),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return result


@app.post("/api/channel-remote-attributes/convert-to-options")
async def channel_remote_attributes_convert_to_options(payload: Dict[str, Any] = Body(...)):
    """Scan master values and seed remote select options; enables future auto-create."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_remote_attributes import promote_attribute_to_options

    try:
        with get_session() as session:
            result = promote_attribute_to_options(
                session,
                channel_type=str(payload.get("channel_type") or ""),
                connection_id=int(payload.get("connection_id")),
                channel_attribute_code=str(payload.get("channel_attribute_code") or ""),
                canonical_code=payload.get("canonical_code"),
                dry_run=bool(payload.get("dry_run")),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    except Exception as exc:
        raise HTTPException(status_code=500, detail=str(exc))
    return result


# ----------------------------
# Master static field mapping (wide 1:1 master → channel columns)
# ----------------------------


@app.get("/api/master-attributes")
async def master_attributes_list():
    """All master catalog attribute codes (definitions + values + core fields)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_attributes import list_master_attributes

    with get_session() as session:
        rows = list_master_attributes(session)
    return {"status": "ok", "count": len(rows), "attributes": rows}


@app.get("/api/master-attributes/{attribute_code}/values")
async def master_attribute_values_list(
    attribute_code: str,
    search: Optional[str] = Query(None),
    limit: int = Query(100, ge=1, le=500),
):
    """Distinct values for one master attribute (for filter UI autocomplete)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_product_filters import list_master_attribute_values

    with get_session() as session:
        values = list_master_attribute_values(session, attribute_code, search=search, limit=limit)
    return {"status": "ok", "attribute_code": attribute_code, "count": len(values), "values": values}


@app.get("/api/master-attributes.csv")
async def master_attributes_csv():
    """Download all master catalog attribute codes for manual mapping reference."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_attributes import export_master_attributes_csv_rows

    with get_session() as session:
        rows = export_master_attributes_csv_rows(session)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=master_attributes.csv"},
    )


@app.get("/api/channel-attribute-options.csv")
async def channel_attribute_options_csv(
    channel_code: str = Query(...),
    connection_id: Optional[int] = Query(None),
):
    """Download channel attribute/field registry for manual mapping (optionally scoped to a connection)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_attribute_options import export_channel_attribute_options_csv_rows
    from db.channel_exports import resolve_pipeline_channel_code

    try:
        channel = resolve_pipeline_channel_code(channel_code)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))

    with get_session() as session:
        rows = export_channel_attribute_options_csv_rows(
            session,
            channel,
            connection_id=connection_id,
        )
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    suffix = f"_{connection_id}" if connection_id else ""
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={
            "Content-Disposition": f"attachment; filename={channel}_attributes{suffix}.csv",
        },
    )


@app.get("/api/channel-connections/{connection_id}/products.csv")
async def compat_connection_products_csv(
    connection_id: int,
    limit: Optional[int] = Query(None, ge=1, le=50000),
):
    """Download current source product snapshots for this connection as a wide CSV."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.source_snapshot_export import export_source_snapshot_csv_rows, ordered_csv_columns

    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        channel = connection["channel_type"]
        native_id = connection["native_id"] if channel in {"magento", "shopify"} else None
        rows = export_source_snapshot_csv_rows(
            session,
            channel,
            connection_id=native_id,
            limit=limit,
        )
    columns = ordered_csv_columns(rows)
    df = pd.DataFrame(rows, columns=columns)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={
            "Content-Disposition": f"attachment; filename={channel}_connection_{connection_id}_products.csv",
        },
    )


@app.get("/api/channel-connections/{connection_id}/products/columns")
async def compat_connection_products_columns(connection_id: int):
    """List export column names discovered from current source snapshots."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.source_snapshot_export import count_current_source_snapshots, export_source_snapshot_columns

    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        channel = connection["channel_type"]
        native_id = connection["native_id"] if channel in {"magento", "shopify"} else None
        columns = export_source_snapshot_columns(session, channel, connection_id=native_id)
        count = count_current_source_snapshots(session, channel, connection_id=native_id)
    return {
        "status": "ok",
        "channel_type": channel,
        "connection_id": connection_id,
        "snapshot_count": count,
        "column_count": len(columns),
        "columns": columns,
    }


@app.get("/api/channel-connections/{connection_id}/taxonomy.csv")
async def compat_connection_taxonomy_csv(connection_id: int):
    """Download channel taxonomy snapshot (Magento categories, Shopify collections, Plytix paths)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_taxonomy import export_taxonomy_csv_rows

    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        channel = connection["channel_type"]
        native_id = connection["native_id"] if channel in {"magento", "shopify"} else None
        rows = export_taxonomy_csv_rows(session, channel, connection_id=native_id)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={
            "Content-Disposition": f"attachment; filename={channel}_connection_{connection_id}_taxonomy.csv",
        },
    )


@app.get("/api/channel-taxonomy-mappings")
async def list_channel_taxonomy_mappings(
    channel_code: str = Query(...),
    connection_id: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_taxonomy import list_taxonomy_mappings

    with get_session() as session:
        rows = list_taxonomy_mappings(session, channel_code=channel_code.strip().lower(), connection_id=connection_id)
    return {"status": "ok", "count": len(rows), "mappings": rows}


@app.get("/api/channel-taxonomy-mappings.csv")
async def export_channel_taxonomy_mappings_csv(
    channel_code: str = Query(...),
    connection_id: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_taxonomy import list_taxonomy_mappings

    with get_session() as session:
        rows = list_taxonomy_mappings(session, channel_code=channel_code.strip().lower(), connection_id=connection_id)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": f"attachment; filename={channel_code}_taxonomy_mappings.csv"},
    )


@app.post("/api/channel-taxonomy-mappings/csv")
async def import_channel_taxonomy_mappings_csv(
    file: UploadFile = File(...),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_taxonomy import import_taxonomy_mapping_csv

    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}") from exc
    with get_session() as session:
        stats = import_taxonomy_mapping_csv(session, df)
        session.commit()
    return {"status": "ok", **stats}


# ----------------------------
# Product channel taxonomy (per-SKU Magento categories / Shopify product category)
# ----------------------------


@app.get("/api/product-channel-taxonomy")
async def product_channel_taxonomy_list(
    channel_code: Optional[str] = Query(None),
    connection_id: Optional[int] = Query(None),
    master_sku: Optional[str] = Query(None),
    status: Optional[str] = Query("active"),
    limit: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import list_sku_taxonomy_assignments

    with get_session() as session:
        rows = list_sku_taxonomy_assignments(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            master_sku=master_sku,
            status=status,
            limit=limit,
        )
    return {"status": "ok", "count": len(rows), "assignments": rows}


@app.get("/api/product-channel-taxonomy.csv")
async def product_channel_taxonomy_csv(
    channel_code: Optional[str] = Query(None),
    connection_id: Optional[int] = Query(None),
    status: Optional[str] = Query("active"),
):
    from db.product_channel_taxonomy import list_sku_taxonomy_assignments

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = list_sku_taxonomy_assignments(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            status=status,
        )
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=product_channel_taxonomy.csv"},
    )


@app.get("/api/product-channel-taxonomy/magento-assignments.csv")
async def product_channel_taxonomy_magento_assignments_csv(
    connection_id: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
):
    from db.product_channel_taxonomy import export_magento_category_assignment_rows

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        native_id = _resolve_native_connection_id(session, connection_id) if connection_id else _default_native_connection_id(session, "magento")
        rows = export_magento_category_assignment_rows(
            session,
            connection_id=native_id,
            only_assigned=only_assigned,
        )
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=magento_sku_category_assignments.csv"},
    )


@app.post("/api/product-channel-taxonomy/upsert")
async def product_channel_taxonomy_upsert(
    master_sku: str = Body(...),
    channel_code: str = Body(...),
    remote_id: str = Body(...),
    taxonomy_kind: str = Body("category"),
    connection_id: Optional[int] = Body(None),
    remote_path: Optional[str] = Body(None),
    assignment_status: str = Body("active"),
    sort_order: int = Body(0),
    notes: Optional[str] = Body(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import upsert_sku_taxonomy_assignment

    try:
        with get_session() as session:
            row = upsert_sku_taxonomy_assignment(
                session,
                master_sku=master_sku,
                channel_code=channel_code,
                remote_id=remote_id,
                taxonomy_kind=taxonomy_kind,
                connection_id=connection_id,
                remote_path=remote_path,
                assignment_status=assignment_status,
                match_source="manual",
                sort_order=sort_order,
                notes=notes,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", "assignment": row}


@app.post("/api/product-channel-taxonomy/csv")
async def product_channel_taxonomy_import_csv(file: UploadFile = File(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    from db.product_channel_taxonomy import import_sku_taxonomy_assignments_csv

    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}") from exc
    with get_session() as session:
        stats = import_sku_taxonomy_assignments_csv(session, df)
        session.commit()
    return {"status": "ok", **stats}


@app.post("/api/product-channel-taxonomy/auto-assign")
async def product_channel_taxonomy_auto_assign(
    channel_code: str = Body(..., embed=True),
    connection_id: Optional[int] = Body(None, embed=True),
    only_assigned: bool = Body(True, embed=True),
    dry_run: bool = Body(False, embed=True),
    include_shopify_product_category: bool = Body(True, embed=True),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import (
        auto_assign_from_path_mappings,
        auto_assign_shopify_product_categories,
    )

    with get_session() as session:
        native_id = _resolve_native_connection_id(session, connection_id) if connection_id else None
        result = auto_assign_from_path_mappings(
            session,
            channel_code=channel_code,
            connection_id=native_id,
            only_assigned=only_assigned,
            dry_run=dry_run,
        )
        if include_shopify_product_category and channel_code.strip().lower() == "shopify":
            result["shopify_product_category"] = auto_assign_shopify_product_categories(
                session,
                connection_id=native_id,
                only_assigned=only_assigned,
                dry_run=dry_run,
            )
        if not dry_run:
            session.commit()
    return {"status": "ok", **result}


@app.post("/api/product-channel-taxonomy/bulk-assign")
async def product_channel_taxonomy_bulk_assign(
    channel_code: str = Body(...),
    remote_ids: List[str] = Body(...),
    taxonomy_kind: str = Body("category"),
    connection_id: Optional[int] = Body(None),
    skus: Optional[List[str]] = Body(None),
    only_assigned: bool = Body(True),
    category_l1: Optional[str] = Body(None),
    category_l2: Optional[str] = Body(None),
    category_l3: Optional[str] = Body(None),
    collection: Optional[str] = Body(None),
    product_family: Optional[str] = Body(None),
    search: Optional[str] = Body(None),
    master_filters: Optional[List[dict]] = Body(None),
    remote_path: Optional[str] = Body(None),
    replace_existing: bool = Body(False),
    dry_run: bool = Body(False),
    limit: Optional[int] = Body(None),
):
    """Filter master SKUs and assign Magento categories / Shopify collections / Shopify product categories."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import bulk_assign_sku_taxonomy

    try:
        with get_session() as session:
            native_id = _resolve_native_connection_id(session, connection_id) if connection_id else None
            result = bulk_assign_sku_taxonomy(
                session,
                channel_code=channel_code,
                remote_ids=remote_ids,
                taxonomy_kind=taxonomy_kind,
                connection_id=native_id,
                skus=skus,
                only_assigned=only_assigned,
                category_l1=category_l1,
                category_l2=category_l2,
                category_l3=category_l3,
                collection=collection,
                product_family=product_family,
                search=search,
                master_filters=master_filters,
                remote_path=remote_path,
                replace_existing=replace_existing,
                dry_run=dry_run,
                limit=limit,
            )
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", **result}


@app.post("/api/product-channel-taxonomy/bulk-remove")
async def product_channel_taxonomy_bulk_remove(
    channel_code: str = Body(..., embed=True),
    taxonomy_kind: Optional[str] = Body(None, embed=True),
    remote_ids: Optional[List[str]] = Body(None, embed=True),
    connection_id: Optional[int] = Body(None, embed=True),
    skus: Optional[List[str]] = Body(None, embed=True),
    only_assigned: bool = Body(False, embed=True),
    category_l1: Optional[str] = Body(None, embed=True),
    category_l2: Optional[str] = Body(None, embed=True),
    category_l3: Optional[str] = Body(None, embed=True),
    collection: Optional[str] = Body(None, embed=True),
    product_family: Optional[str] = Body(None, embed=True),
    search: Optional[str] = Body(None, embed=True),
    master_filters: Optional[List[dict]] = Body(None, embed=True),
    dry_run: bool = Body(False, embed=True),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import bulk_remove_sku_taxonomy

    with get_session() as session:
        native_id = _resolve_native_connection_id(session, connection_id) if connection_id else None
        result = bulk_remove_sku_taxonomy(
            session,
            channel_code=channel_code,
            taxonomy_kind=taxonomy_kind,
            remote_ids=remote_ids,
            connection_id=native_id,
            skus=skus,
            only_assigned=only_assigned,
            category_l1=category_l1,
            category_l2=category_l2,
            category_l3=category_l3,
            collection=collection,
            product_family=product_family,
            search=search,
            master_filters=master_filters,
            dry_run=dry_run,
        )
        if not dry_run:
            session.commit()
    return {"status": "ok", **result}


@app.get("/api/product-channel-taxonomy/shopify-collection-readiness")
async def product_channel_taxonomy_shopify_collection_readiness(
    master_sku: str = Query(...),
    shopify_connection_id: Optional[int] = Query(None),
):
    """Preview manual vs smart Shopify collection membership for one master SKU."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.shopify_collection_readiness import evaluate_shopify_collection_assignments_for_sku

    with get_session() as session:
        try:
            result = evaluate_shopify_collection_assignments_for_sku(
                session,
                master_sku=master_sku,
                connection_id=shopify_connection_id,
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.get("/api/product-channel-taxonomy/filter-skus")
async def product_channel_taxonomy_filter_skus(
    channel_code: Optional[str] = Query(None),
    only_assigned: bool = Query(True),
    category_l1: Optional[str] = Query(None),
    category_l2: Optional[str] = Query(None),
    category_l3: Optional[str] = Query(None),
    collection: Optional[str] = Query(None),
    product_family: Optional[str] = Query(None),
    search: Optional[str] = Query(None),
    master_filters: Optional[str] = Query(None, description="JSON array: [{code,value,match}]"),
    limit: Optional[int] = Query(200),
):
    """Preview which master SKUs match assignment filters before bulk-assign."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import resolve_filtered_master_skus

    try:
        with get_session() as session:
            skus = resolve_filtered_master_skus(
                session,
                channel_code=channel_code,
                only_assigned=only_assigned,
                category_l1=category_l1,
                category_l2=category_l2,
                category_l3=category_l3,
                collection=collection,
                product_family=product_family,
                search=search,
                master_filters=master_filters,
                limit=limit,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "count": len(skus), "skus": skus}


@app.get("/api/product-channel-taxonomy/shopify-assignments.csv")
async def product_channel_taxonomy_shopify_assignments_csv(
    connection_id: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
):
    from db.product_channel_taxonomy import export_shopify_assignment_rows

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        native_id = _resolve_native_connection_id(session, connection_id) if connection_id else _default_native_connection_id(session, "shopify")
        rows = export_shopify_assignment_rows(
            session,
            connection_id=native_id,
            only_assigned=only_assigned,
        )
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=shopify_sku_taxonomy_assignments.csv"},
    )


@app.get("/api/shopify/collections")
async def shopify_collections_list(
    connection_id: Optional[int] = Query(None),
    search: Optional[str] = Query(None),
    limit: Optional[int] = Query(500),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from shopify.collection_sync import list_shopify_collections

    with get_session() as session:
        native_id = _resolve_native_connection_id(session, connection_id) if connection_id else _default_native_connection_id(session, "shopify")
        rows = list_shopify_collections(session, connection_id=native_id, search=search, limit=limit)
    return {"status": "ok", "count": len(rows), "collections": rows}


@app.post("/api/shopify/collections/pull")
async def shopify_collections_pull(
    connection_id: Optional[int] = Body(None, embed=True),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.models import ShopifyConnection
    from shopify.collection_sync import pull_shopify_collections
    from shopify.connections import build_client, get_active_connection

    with get_session() as session:
        connection = None
        if connection_id is not None:
            native_id = _resolve_native_connection_id(session, connection_id)
            if native_id is not None:
                connection = session.get(ShopifyConnection, native_id)
        if connection is None:
            connection = get_active_connection(session)
        if connection is None:
            raise HTTPException(status_code=404, detail="No Shopify connection available")
        if connection.status != "active":
            raise HTTPException(status_code=409, detail="Shopify connection is disabled")
        client = build_client(connection)
        stats = pull_shopify_collections(
            client,
            shop_code=connection.shop_code,
            connection_id=connection.id,
            session=session,
        )
        session.commit()
    return {"status": "ok", **stats}


@app.post("/api/shopify/collections")
async def shopify_collections_create(
    title: str = Body(...),
    handle: Optional[str] = Body(None),
    description_html: Optional[str] = Body(None),
    connection_id: Optional[int] = Body(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.models import ShopifyConnection
    from shopify.collection_sync import create_shopify_collection
    from shopify.connections import build_client, get_active_connection

    try:
        with get_session() as session:
            connection = None
            if connection_id is not None:
                native_id = _resolve_native_connection_id(session, connection_id)
                if native_id is not None:
                    connection = session.get(ShopifyConnection, native_id)
            if connection is None:
                connection = get_active_connection(session)
            if connection is None:
                raise HTTPException(status_code=404, detail="No Shopify connection available")
            if connection.status != "active":
                raise HTTPException(status_code=409, detail="Shopify connection is disabled")
            client = build_client(connection)
            created = create_shopify_collection(
                session,
                client,
                shop_code=connection.shop_code,
                connection_id=connection.id,
                title=title,
                handle=handle,
                description_html=description_html,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    except RuntimeError as exc:
        raise HTTPException(status_code=502, detail=str(exc))
    return {"status": "ok", "collection": created}


@app.get("/api/shopify/taxonomy")
async def shopify_taxonomy_registry_list(
    search: Optional[str] = Query(None),
    limit: Optional[int] = Query(500),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from shopify.taxonomy_sync import list_taxonomy_registry_rows

    with get_session() as session:
        rows = list_taxonomy_registry_rows(session, search=search, limit=limit)
    return {"status": "ok", "count": len(rows), "categories": rows}


@app.post("/api/shopify/taxonomy/pull")
async def shopify_taxonomy_registry_pull(
    connection_id: Optional[int] = Body(None, embed=True),
    include_descendants: bool = Body(True, embed=True),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.models import ShopifyConnection
    from shopify.connections import build_client, get_active_connection
    from shopify.taxonomy_sync import pull_shopify_taxonomy_registry

    with get_session() as session:
        connection = None
        if connection_id is not None:
            native_id = _resolve_native_connection_id(session, connection_id)
            if native_id is not None:
                connection = session.get(ShopifyConnection, native_id)
        if connection is None:
            connection = get_active_connection(session)
        if connection is None:
            raise HTTPException(status_code=404, detail="No Shopify connection available")
        if connection.status != "active":
            raise HTTPException(status_code=409, detail="Shopify connection is disabled")
        client = build_client(connection)
        stats = pull_shopify_taxonomy_registry(session, client, include_descendants=include_descendants)
        session.commit()
    return {"status": "ok", **stats}


@app.post("/api/channel-sku-mappings/link-all")
async def channel_sku_mappings_link_all(
    only_assigned: bool = Body(True, embed=True),
    dry_run: bool = Body(False, embed=True),
    pull_shopify_taxonomy: bool = Body(True, embed=True),
    auto_assign_taxonomy: bool = Body(True, embed=True),
):
    """Link master SKUs across Magento/Shopify/Plytix, pull Shopify taxonomy, seed category assignments."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.catalog_link_bootstrap import run_catalog_link_bootstrap

    result = run_catalog_link_bootstrap(
        only_assigned=only_assigned,
        dry_run=dry_run,
        pull_shopify_taxonomy=pull_shopify_taxonomy,
        auto_assign_taxonomy=auto_assign_taxonomy,
    )
    return result


def _start_channel_catalog_provision_job(
    *,
    dry_run: bool,
    magento_connection_id: Optional[int] = None,
    shopify_connection_id: Optional[int] = None,
    only_assigned: bool = True,
    include_magento: bool = True,
    include_shopify: bool = True,
    create_missing: bool = True,
    assign_skus: bool = True,
    delta_only: bool = True,
    seed_master_listing_paths: bool = True,
    provision_attributes: bool = True,
    include_brand_collections: bool = True,
    shopify_brand_filter: Optional[List[str]] = None,
    limit: Optional[int] = None,
    baseline_first: bool = False,
) -> Dict[str, Any]:
    from app.jobs.channel_catalog_provision_enqueue import start_catalog_provision_job

    params = {
        "magento_connection_id": magento_connection_id,
        "shopify_connection_id": shopify_connection_id,
        "only_assigned": only_assigned,
        "dry_run": dry_run,
        "include_magento": include_magento,
        "include_shopify": include_shopify,
        "create_missing": create_missing,
        "assign_skus": assign_skus,
        "delta_only": delta_only,
        "seed_master_listing_paths": seed_master_listing_paths,
        "provision_attributes": provision_attributes,
        "include_brand_collections": include_brand_collections,
        "shopify_brand_filter": shopify_brand_filter or [],
        "limit": limit,
        "baseline_first": baseline_first,
    }
    job_id = start_catalog_provision_job(params)
    return {
        "status": "accepted",
        "job_id": job_id,
        "dry_run": dry_run,
        "poll_url": f"/api/channel-catalogs/provision/{job_id}",
        "message": "Accepted; poll poll_url until status is completed or failed.",
    }


def _start_channel_schema_sync_job(
    *,
    dry_run: bool,
    magento_connection_id: Optional[int] = None,
    shopify_connection_id: Optional[int] = None,
    only_assigned: bool = True,
    include_magento: bool = True,
    include_shopify: bool = True,
    create_missing: bool = True,
    provision_attributes: bool = True,
    include_brand_collections: bool = True,
    shopify_brand_filter: Optional[List[str]] = None,
    limit: Optional[int] = None,
    baseline_first: bool = True,
) -> Dict[str, Any]:
    result = _start_channel_catalog_provision_job(
        dry_run=dry_run,
        magento_connection_id=magento_connection_id,
        shopify_connection_id=shopify_connection_id,
        only_assigned=only_assigned,
        include_magento=include_magento,
        include_shopify=include_shopify,
        create_missing=create_missing,
        assign_skus=False,
        delta_only=True,
        seed_master_listing_paths=False,
        provision_attributes=provision_attributes,
        include_brand_collections=include_brand_collections,
        shopify_brand_filter=shopify_brand_filter,
        limit=limit,
        baseline_first=baseline_first,
    )
    result["sync_mode"] = "channel_schema"
    result["schema_only"] = True
    return result


@app.post("/api/channel-catalogs/provision", status_code=202)
async def channel_catalogs_provision(
    magento_connection_id: Optional[int] = Body(None),
    shopify_connection_id: Optional[int] = Body(None),
    only_assigned: bool = Body(True),
    dry_run: bool = Body(True),
    include_magento: bool = Body(True),
    include_shopify: bool = Body(True),
    create_missing: bool = Body(True),
    assign_skus: bool = Body(True),
    delta_only: bool = Body(True),
    seed_master_listing_paths: bool = Body(True),
    provision_attributes: bool = Body(True),
    include_brand_collections: bool = Body(True),
    shopify_brand_filter: Optional[List[str]] = Body(None),
    limit: Optional[int] = Body(None),
    baseline_first: bool = Body(False),
):
    """Preview catalog provision (dry_run=true default). Poll GET .../provision/{job_id}."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    return _start_channel_catalog_provision_job(
        dry_run=dry_run,
        magento_connection_id=magento_connection_id,
        shopify_connection_id=shopify_connection_id,
        only_assigned=only_assigned,
        include_magento=include_magento,
        include_shopify=include_shopify,
        create_missing=create_missing,
        assign_skus=assign_skus,
        delta_only=delta_only,
        seed_master_listing_paths=seed_master_listing_paths,
        provision_attributes=provision_attributes,
        include_brand_collections=include_brand_collections,
        shopify_brand_filter=shopify_brand_filter,
        limit=limit,
        baseline_first=baseline_first,
    )


@app.post("/api/channel-catalogs/provision/apply", status_code=202)
async def channel_catalogs_provision_apply(
    magento_connection_id: Optional[int] = Body(None),
    shopify_connection_id: Optional[int] = Body(None),
    only_assigned: bool = Body(True),
    include_magento: bool = Body(True),
    include_shopify: bool = Body(True),
    create_missing: bool = Body(True),
    assign_skus: bool = Body(True),
    delta_only: bool = Body(True),
    seed_master_listing_paths: bool = Body(True),
    provision_attributes: bool = Body(True),
    include_brand_collections: bool = Body(True),
    shopify_brand_filter: Optional[List[str]] = Body(None),
    limit: Optional[int] = Body(None),
    baseline_first: bool = Body(True),
):
    """One-click create: Magento categories, Shopify collections, attributes, and options."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    return _start_channel_catalog_provision_job(
        dry_run=False,
        magento_connection_id=magento_connection_id,
        shopify_connection_id=shopify_connection_id,
        only_assigned=only_assigned,
        include_magento=include_magento,
        include_shopify=include_shopify,
        create_missing=create_missing,
        assign_skus=assign_skus,
        delta_only=delta_only,
        seed_master_listing_paths=seed_master_listing_paths,
        provision_attributes=provision_attributes,
        include_brand_collections=include_brand_collections,
        shopify_brand_filter=shopify_brand_filter,
        limit=limit,
        baseline_first=baseline_first,
    )


@app.post("/api/channel-schema/sync", status_code=202)
async def channel_schema_sync(
    magento_connection_id: Optional[int] = Body(None),
    shopify_connection_id: Optional[int] = Body(None),
    only_assigned: bool = Body(True),
    dry_run: bool = Body(True),
    include_magento: bool = Body(True),
    include_shopify: bool = Body(True),
    create_missing: bool = Body(True),
    provision_attributes: bool = Body(True),
    include_brand_collections: bool = Body(True),
    shopify_brand_filter: Optional[List[str]] = Body(None),
    limit: Optional[int] = Body(None),
    baseline_first: bool = Body(True),
):
    """Preview a schema-only outbound sync: categories/collections + attributes/options + local registry linking."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    return _start_channel_schema_sync_job(
        dry_run=dry_run,
        magento_connection_id=magento_connection_id,
        shopify_connection_id=shopify_connection_id,
        only_assigned=only_assigned,
        include_magento=include_magento,
        include_shopify=include_shopify,
        create_missing=create_missing,
        provision_attributes=provision_attributes,
        include_brand_collections=include_brand_collections,
        shopify_brand_filter=shopify_brand_filter,
        limit=limit,
        baseline_first=baseline_first,
    )


@app.post("/api/channel-schema/sync/apply", status_code=202)
async def channel_schema_sync_apply(
    magento_connection_id: Optional[int] = Body(None),
    shopify_connection_id: Optional[int] = Body(None),
    only_assigned: bool = Body(True),
    include_magento: bool = Body(True),
    include_shopify: bool = Body(True),
    create_missing: bool = Body(True),
    provision_attributes: bool = Body(True),
    include_brand_collections: bool = Body(True),
    shopify_brand_filter: Optional[List[str]] = Body(None),
    limit: Optional[int] = Body(None),
    baseline_first: bool = Body(True),
):
    """Apply a schema-only outbound sync before product data pushes."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    return _start_channel_schema_sync_job(
        dry_run=False,
        magento_connection_id=magento_connection_id,
        shopify_connection_id=shopify_connection_id,
        only_assigned=only_assigned,
        include_magento=include_magento,
        include_shopify=include_shopify,
        create_missing=create_missing,
        provision_attributes=provision_attributes,
        include_brand_collections=include_brand_collections,
        shopify_brand_filter=shopify_brand_filter,
        limit=limit,
        baseline_first=baseline_first,
    )


@app.post("/api/channel-full-publish")
async def channel_full_publish(payload: dict = Body(...)):
    """One-click downstream publish: taxonomy, attributes, products, and images."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    def native_id_for(channel: str, value: Any) -> Optional[int]:
        if value in (None, ""):
            return None
        decoded_channel, native_id = _decode_compat_id(int(value))
        if decoded_channel != channel:
            raise HTTPException(status_code=400, detail=f"{channel}_connection_id does not reference {channel}")
        return int(native_id)

    channels = payload.get("channels")
    if channels is not None:
        channels = [str(ch).strip().lower() for ch in channels if str(ch).strip()]
        invalid = sorted(set(channels) - {"magento", "shopify"})
        if invalid:
            raise HTTPException(status_code=400, detail=f"Unsupported channel(s): {', '.join(invalid)}")

    from app.jobs.channel_full_publish import run_channel_full_publish

    result = run_channel_full_publish(
        dry_run=bool(payload.get("dry_run", True)),
        channels=channels,
        magento_connection_id=native_id_for("magento", payload.get("magento_connection_id")),
        shopify_connection_id=native_id_for("shopify", payload.get("shopify_connection_id")),
        skus=[str(s).strip() for s in (payload.get("skus") or []) if str(s).strip()] or None,
        only_assigned=bool(payload.get("only_assigned", True)),
        skip_pull=bool(payload.get("skip_pull", False)),
        skip_taxonomy=bool(payload.get("skip_taxonomy", False)),
        skip_attributes=bool(payload.get("skip_attributes", False)),
        skip_products=bool(payload.get("skip_products", False)),
        skip_images=bool(payload.get("skip_images", False)),
        wait=bool(payload.get("wait", False)),
        run_worker=bool(payload.get("run_worker", False)),
        wait_timeout=int(payload.get("wait_timeout") or 7200),
        batch_size=max(1, int(payload.get("batch_size") or 500)),
        reprocess_master=bool(payload.get("reprocess_master", False)),
    )
    if result.get("status") == "failed":
        raise HTTPException(status_code=400, detail=result.get("error") or "Full publish failed")
    return result


@app.get("/api/channel-catalogs/provision/{job_id}")
async def channel_catalogs_provision_status(job_id: str):
    """Poll background catalog provision job status and result."""
    from app.jobs.channel_catalog_provision_enqueue import get_catalog_provision_job

    row = get_catalog_provision_job(job_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Provision job not found")
    payload: Dict[str, Any] = {
        "job_id": row.get("id") or job_id,
        "status": row.get("status") or "unknown",
        "created_at": row.get("created_at"),
        "started_at": row.get("started_at"),
        "finished_at": row.get("finished_at"),
        "dry_run": (row.get("params") or {}).get("dry_run"),
        "terminal": row.get("status") in {"completed", "failed"},
    }
    if row.get("error"):
        payload["error"] = row["error"]
    if row.get("result") is not None:
        payload["result"] = row["result"]
    return payload


def _default_native_connection_id(session, channel_type: str) -> Optional[int]:
    for conn in _all_compat_connections(session):
        if conn.get("channel_type") == channel_type:
            return conn.get("native_id")
    return None


def _resolve_native_connection_id(session, connection_id: Optional[int]) -> Optional[int]:
    if connection_id is None:
        return None
    row = _connection_row(session, connection_id)
    if row:
        return row.get("native_id")
    return connection_id


@app.get("/api/product-snapshots")
async def list_product_snapshots(
    channel: str = Query("master"),
    page: int = Query(1, ge=1),
    page_size: int = Query(50, ge=1, le=200),
    q: Optional[str] = Query(None),
    connection_id: Optional[int] = Query(None),
):
    """Paginated product snapshot rows for one channel at a time."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_browser import paginate_product_snapshots

    channel_norm = (channel or "master").strip().lower()
    with get_session() as session:
        native_id = _resolve_native_connection_id(session, connection_id)
        if native_id is None and channel_norm in {"magento", "shopify"}:
            native_id = _default_native_connection_id(session, channel_norm)
        try:
            payload = paginate_product_snapshots(
                session,
                channel_norm,
                page=page,
                page_size=page_size,
                q=q,
                connection_id=native_id,
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **payload}


@app.get("/api/product-snapshots.csv")
async def export_product_snapshots_csv(
    channel: str = Query("master"),
    q: Optional[str] = Query(None),
    connection_id: Optional[int] = Query(None),
    limit: Optional[int] = Query(50000, ge=1, le=50000),
):
    """Download full product snapshot CSV for the selected channel."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_browser import export_snapshot_csv_rows
    from db.source_snapshot_export import ordered_csv_columns

    channel_norm = (channel or "master").strip().lower()
    with get_session() as session:
        native_id = _resolve_native_connection_id(session, connection_id)
        if native_id is None and channel_norm in {"magento", "shopify"}:
            native_id = _default_native_connection_id(session, channel_norm)
        rows = export_snapshot_csv_rows(
            session,
            channel_norm,
            connection_id=native_id,
            q=q,
            limit=limit,
        )
    columns = ordered_csv_columns(rows)
    df = pd.DataFrame(rows, columns=columns)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": f"attachment; filename={channel_norm}_product_snapshots.csv"},
    )


@app.get("/api/products/matrix")
async def list_product_matrix(
    page: int = Query(1, ge=1),
    page_size: int = Query(50, ge=1, le=200),
    q: Optional[str] = Query(None),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    max_fields: int = Query(48, ge=8, le=120),
    master_filters: Optional[str] = Query(None, description="JSON array: [{code,value,match}]"),
):
    """Paginated cross-channel product view keyed by master SKU."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_browser import paginate_product_matrix

    try:
        with get_session() as session:
            magento_native = _resolve_native_connection_id(session, magento_connection_id)
            shopify_native = _resolve_native_connection_id(session, shopify_connection_id)
            if magento_native is None:
                magento_native = _default_native_connection_id(session, "magento")
            if shopify_native is None:
                shopify_native = _default_native_connection_id(session, "shopify")
            payload = paginate_product_matrix(
                session,
                page=page,
                page_size=page_size,
                q=q,
                magento_connection_id=magento_native,
                shopify_connection_id=shopify_native,
                max_fields=max_fields,
                master_filters=master_filters,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **payload}


@app.get("/api/products/enrichment-profiles")
async def list_enrichment_profiles():
    """Explicit one-time field groups for isolated channel enrichment pulls."""
    from db.source_snapshot_enrichment import list_enrichment_profiles

    profiles = list_enrichment_profiles()
    return {"status": "ok", "profiles": profiles}


@app.post("/api/channel-connections/{connection_id}/enrichment-pull")
async def channel_enrichment_pull(connection_id: int, payload: dict = Body(...)):
    """One-time pull for explicit field groups (images, Magento option attrs) merged into snapshots."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.channel_enrichment_pull import run_channel_enrichment_pull

    profiles = payload.get("profiles") or []
    if not profiles:
        raise HTTPException(status_code=400, detail="profiles is required (e.g. images, magento_custom_attributes)")
    dry_run = bool(payload.get("dry_run", False))
    skus = payload.get("skus")
    limit = payload.get("limit")
    try:
        with get_session() as session:
            connection = _connection_row(session, connection_id)
            if not connection:
                raise HTTPException(status_code=404, detail="Connection not found")
            result = run_channel_enrichment_pull(
                session,
                channel_type=connection["channel_type"],
                connection_id=connection_id,
                native_connection_id=connection["native_id"],
                profiles=profiles,
                skus=skus,
                dry_run=dry_run,
                limit=limit,
            )
            if not dry_run and result.get("status") == "ok":
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    except RuntimeError as exc:
        raise HTTPException(status_code=502, detail=str(exc))
    if result.get("status") == "failed":
        raise HTTPException(status_code=400, detail=result.get("error", "Enrichment pull failed"))
    return {"status": "ok", **result}


@app.get("/api/channel-connections/{connection_id}/attributes.csv")
async def compat_connection_attributes_csv(connection_id: int):
    """Download attribute registry for this connection's channel (compat connection id)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_attribute_options import export_channel_attribute_options_csv_rows

    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        pipeline_code = _pipeline_channel_code(connection)
        rows = export_channel_attribute_options_csv_rows(
            session,
            pipeline_code,
            connection_id=connection_id,
        )
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={
            "Content-Disposition": f"attachment; filename={pipeline_code}_connection_{connection_id}_attributes.csv",
        },
    )


@app.get("/api/channel-attribute-options")
async def channel_attribute_options_api(
    channel_code: Optional[str] = Query(None),
    connection_id: Optional[int] = Query(None),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
):
    """Channel attribute codes for mapping dropdowns (registry / attribute_def)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_attribute_options import channel_attribute_options, channel_attribute_options_bundle

    with get_session() as session:
        if channel_code:
            options = channel_attribute_options(session, channel_code, connection_id=connection_id)
            return {"status": "ok", "channel_code": channel_code, "options": options}
        bundle = channel_attribute_options_bundle(
            session,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
        )
    return {"status": "ok", "channel_options": bundle}


@app.get("/api/master-skus/search")
async def master_skus_search(q: Optional[str] = Query(None), limit: int = Query(25, ge=1, le=200)):
    """Autocomplete master SKUs for manual SKU mapping."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_attributes import search_master_skus

    with get_session() as session:
        skus = search_master_skus(session, query=q, limit=limit)
    return {"status": "ok", "count": len(skus), "skus": skus}


@app.get("/api/master-static-field-mappings")
async def master_static_field_mappings_table(
    include_inactive: bool = Query(False),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
):
    """Pivot table: all master attributes on the left, Magento/Shopify/Plytix columns on the right."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_static_field_mapping import build_static_field_mapping_table

    with get_session() as session:
        table = build_static_field_mapping_table(
            session,
            include_inactive=include_inactive,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
            include_channel_options=True,
        )
        return {"status": "ok", **table}


@app.get("/api/master-static-field-mappings.csv")
async def master_static_field_mappings_csv(
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
):
    """Download the wide static field mapping table as CSV."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_static_field_mapping import export_static_field_mapping_csv_rows

    with get_session() as session:
        rows = export_static_field_mapping_csv_rows(
            session,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
        )
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=master_static_field_mappings.csv"},
    )


@app.post("/api/master-static-field-mappings/csv")
async def import_master_static_field_mappings_csv(
    file: UploadFile = File(...),
    sync_aliases: bool = Query(True),
):
    """Upload wide static mapping CSV and optionally sync to channel_attribute_alias."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    from db.master_static_field_mapping import import_static_field_mapping_dataframe

    with get_session() as session:
        stats = import_static_field_mapping_dataframe(session, df, sync_aliases=sync_aliases)
        session.commit()
    return {"status": "ok", "file": file.filename, **stats}


@app.post("/api/master-static-field-mappings/bootstrap")
async def bootstrap_master_static_field_mappings(sync_aliases: bool = Query(False)):
    """Seed left-column master static attributes (title, brand, SEO, price, etc.)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_static_field_mapping import bootstrap_static_field_rows

    with get_session() as session:
        stats = bootstrap_static_field_rows(session, sync_aliases=sync_aliases)
        session.commit()
    return {"status": "ok", **stats}


@app.post("/api/master-static-field-mappings/auto-match")
async def auto_match_master_static_field_mappings(
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    channels: Optional[str] = Query(None),
    overwrite: bool = Query(False),
    sync_aliases: bool = Query(True),
):
    """Persist exact master-code -> channel-attribute mappings from channel registries."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_static_field_mapping import auto_match_static_field_mappings

    channel_list = [part.strip() for part in channels.split(",")] if channels else None
    with get_session() as session:
        stats = auto_match_static_field_mappings(
            session,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
            channels=channel_list,
            overwrite=overwrite,
            sync_aliases=sync_aliases,
        )
        session.commit()
    return {"status": "ok", **stats}


@app.post("/api/master-static-field-mappings/import-from-aliases")
async def import_master_static_field_mappings_from_aliases():
    """Fold existing channel_attribute_alias rows into the wide static mapping table."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_static_field_mapping import import_wide_mappings_from_channel_aliases

    with get_session() as session:
        stats = import_wide_mappings_from_channel_aliases(session)
        session.commit()
    return {"status": "ok", **stats}


@app.post("/api/master-static-field-mappings/sync-aliases")
async def sync_master_static_field_mappings_to_aliases():
    """Push wide static mappings into channel_attribute_alias for export/push."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_static_field_mapping import sync_static_mappings_to_channel_aliases

    with get_session() as session:
        count = sync_static_mappings_to_channel_aliases(session)
        session.commit()
    return {"status": "ok", "aliases_synced": count}


@app.post("/api/master-static-field-mappings/save")
async def save_master_static_field_mappings(background_tasks: BackgroundTasks, payload: Dict[str, Any] = Body(...)):
    """Save mapping dropdown selections from the dashboard (batch)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_static_field_mapping import save_static_field_mappings_batch

    rows = payload.get("rows") or []
    sync_aliases = bool(payload.get("sync_aliases", True))
    provision_on_save = bool(payload.get("provision_on_save", True))
    magento_connection_id = payload.get("magento_connection_id")
    shopify_connection_id = payload.get("shopify_connection_id")
    if magento_connection_id is not None:
        magento_connection_id = int(magento_connection_id)
    if shopify_connection_id is not None:
        shopify_connection_id = int(shopify_connection_id)

    with get_session() as session:
        stats = save_static_field_mappings_batch(session, rows, sync_aliases=sync_aliases)
        provision: Dict[str, Any] = {}
        if sync_aliases and provision_on_save and stats.get("pending_create_channels"):
            channels = list(stats["pending_create_channels"])
            provision = {"status": "queued", "channels": channels}
            background_tasks.add_task(
                _provision_pending_static_mappings_background,
                channels,
                magento_connection_id,
                shopify_connection_id,
            )
        session.commit()
    return {"status": "ok", **stats, "provision": provision}


def _provision_pending_static_mappings_background(
    channels: List[str],
    magento_connection_id: Optional[int],
    shopify_connection_id: Optional[int],
) -> None:
    from db.channel_attribute_provision import provision_pending_for_channels

    try:
        with get_session() as session:
            provision_pending_for_channels(
                session,
                channels,
                magento_connection_id=magento_connection_id,
                shopify_connection_id=shopify_connection_id,
                dry_run=False,
            )
            session.commit()
    except Exception as exc:
        logger.exception("static-mapping background provision failed: %s", exc)


@app.get("/api/master-channel-mapping-matrix")
async def master_channel_mapping_matrix(
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    include_unmapped: bool = Query(True),
):
    """Master attribute → channel attribute matrix with registry match flags (0/1)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_channel_mapping_matrix import build_master_channel_mapping_matrix

    with get_session() as session:
        matrix = build_master_channel_mapping_matrix(
            session,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
            include_unmapped=include_unmapped,
        )
    return {"status": "ok", **matrix}


@app.get("/api/master-channel-mapping-matrix.csv")
async def master_channel_mapping_matrix_csv(
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    include_unmapped: bool = Query(True),
):
    """Download: Master, magento, magento_is_match, shopify, shopify_is_match, …"""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_channel_mapping_matrix import export_master_channel_mapping_matrix_csv_rows

    with get_session() as session:
        rows = export_master_channel_mapping_matrix_csv_rows(
            session,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
            include_unmapped=include_unmapped,
        )
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=master_channel_mapping_matrix.csv"},
    )


# ----------------------------
# Dashboard compatibility API
# ----------------------------

SHOPIFY_COMPAT_ID_OFFSET = 1_000_000
GENERIC_COMPAT_ID_OFFSET = 2_000_000


def _dt(value: Any) -> Optional[str]:
    return value.isoformat() if value else None


def _compat_id(channel_type: str, native_id: int) -> int:
    if channel_type == "shopify":
        return SHOPIFY_COMPAT_ID_OFFSET + int(native_id)
    if channel_type == "generic":
        return GENERIC_COMPAT_ID_OFFSET + int(native_id)
    return int(native_id)


def _decode_compat_id(connection_id: int) -> tuple[str, int]:
    if connection_id >= GENERIC_COMPAT_ID_OFFSET:
        return "generic", connection_id - GENERIC_COMPAT_ID_OFFSET
    if connection_id >= SHOPIFY_COMPAT_ID_OFFSET:
        return "shopify", connection_id - SHOPIFY_COMPAT_ID_OFFSET
    return "magento", connection_id


def _connection_row(session, connection_id: int) -> Optional[dict]:
    from sqlalchemy import select
    from db.models import ChannelConnection, MagentoConnection, ShopifyConnection

    channel_type, native_id = _decode_compat_id(connection_id)
    if channel_type == "magento":
        row = session.get(MagentoConnection, native_id)
        if not row:
            return None
        return {
            "id": _compat_id("magento", row.id),
            "native_id": row.id,
            "channel_type": "magento",
            "channel_code": row.store_code or row.tenant_id or f"magento-{row.id}",
            "display_name": row.store_base_url,
            "environment": row.environment,
            "base_url": row.store_base_url,
            "api_base_url": row.magento_api_base_url,
            "store_code": row.store_code,
            "status": row.status,
            "config": {
                "store_base_url": row.store_base_url,
                "magento_api_base_url": row.magento_api_base_url,
                "internal_api_base_url": row.internal_api_base_url,
                "internal_host_header": row.internal_host_header,
                "prefer_internal_api": bool(row.prefer_internal_api),
                "rest_base_path": row.rest_base_path or "V1",
                "store_code": row.store_code,
                "signature_method": row.signature_method or "HMAC-SHA256",
            },
            "last_verified_at": _dt(row.last_verified_at),
            "last_error": row.last_error,
        }
    if channel_type == "shopify":
        row = session.get(ShopifyConnection, native_id)
        if not row:
            return None
        return {
            "id": _compat_id("shopify", row.id),
            "native_id": row.id,
            "channel_type": "shopify",
            "channel_code": row.shop_code,
            "display_name": row.shop_code,
            "environment": row.environment,
            "base_url": f"https://{row.shop_domain}",
            "api_base_url": f"https://{row.shop_domain}/admin/api/{row.api_version}",
            "store_code": row.shop_code,
            "status": row.status,
            "last_verified_at": _dt(row.last_verified_at),
            "last_error": row.last_error,
        }
    row = session.get(ChannelConnection, native_id)
    if not row:
        return None
    return {
        "id": _compat_id("generic", row.id),
        "native_id": row.id,
        "channel_type": row.channel_type,
        "channel_code": row.channel_code,
        "display_name": row.display_name or row.channel_code,
        "environment": row.environment,
        "base_url": row.base_url,
        "api_base_url": row.api_base_url,
        "store_code": row.store_code,
        "status": row.status,
        "config": row.config or {},
        "capabilities": row.capabilities or {},
        "last_verified_at": _dt(row.last_verified_at),
        "last_error": row.last_error,
    }


def _all_compat_connections(session) -> List[dict]:
    from sqlalchemy import select
    from db.models import ChannelConnection, MagentoConnection, ShopifyConnection

    rows: List[dict] = []
    for row in session.scalars(select(MagentoConnection).order_by(MagentoConnection.id)).all():
        rows.append(_connection_row(session, _compat_id("magento", row.id)))
    for row in session.scalars(select(ShopifyConnection).order_by(ShopifyConnection.id)).all():
        rows.append(_connection_row(session, _compat_id("shopify", row.id)))
    for row in session.scalars(select(ChannelConnection).order_by(ChannelConnection.id)).all():
        rows.append(_connection_row(session, _compat_id("generic", row.id)))
    return [r for r in rows if r]


def _channel_code_for_connection(connection: dict) -> str:
    """Per-connection display identity (store_code, shop_code, magento-1) — not for pipeline export."""
    return str(connection.get("channel_code") or connection.get("channel_type") or "").strip().lower()


def _pipeline_channel_code(connection: dict) -> str:
    """Canonical master-catalog channel code: magento | shopify | plytix."""
    from db.channel_exports import resolve_pipeline_channel_code

    return resolve_pipeline_channel_code(
        connection.get("channel_code"),
        channel_type=connection.get("channel_type"),
    )


def _job_dict(job) -> dict:
    return {
        "id": job.id,
        "channel_connection_id": _compat_id("magento", job.connection_id),
        "connection_id": job.connection_id,
        "job_type": "push",
        "status": job.status,
        "dry_run": job.dry_run,
        "total_count": job.total_count,
        "success_count": job.success_count,
        "error_count": job.error_count,
        "notes": job.notes,
        "requested_at": _dt(job.requested_at),
        "started_at": _dt(job.started_at),
        "finished_at": _dt(job.finished_at),
    }


def _channel_job_dict(job) -> dict:
    from app.jobs.channel_jobs import job_to_dict

    return job_to_dict(job)


@app.get("/api/catalog-operations/summary")
async def catalog_operations_summary():
    with get_session() as session:
        return build_catalog_operations_summary(session)


@app.get("/api/catalog-operations/registry")
async def catalog_operations_registry(
    channel: Optional[str] = Query(None),
    q: Optional[str] = Query(None),
    issue: Optional[str] = Query(None),
    page: int = Query(1, ge=1),
    page_size: int = Query(50, ge=1, le=200),
):
    with get_session() as session:
        return build_catalog_operations_registry(
            session,
            channel=channel,
            q=q,
            issue=issue,
            page=page,
            page_size=page_size,
        )


def _parse_bool_param(value: Any, *, default: bool = False) -> bool:
    if value is None:
        return default
    if isinstance(value, bool):
        return value
    text = str(value).strip().lower()
    if not text:
        return default
    if text in {"1", "true", "yes", "y", "on"}:
        return True
    if text in {"0", "false", "no", "n", "off"}:
        return False
    return default


@app.get("/api/catalog-normalization/overview")
async def catalog_normalization_overview():
    with get_session() as session:
        return build_normalization_overview(session)


@app.get("/api/catalog-normalization/profiles")
async def catalog_normalization_profiles():
    with get_session() as session:
        return list_normalization_profiles(session)


@app.post("/api/catalog-normalization/profiles")
async def catalog_normalization_profile_create(payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = upsert_normalization_profile(session, payload=payload)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
        return result


@app.put("/api/catalog-normalization/profiles/{profile_id}")
async def catalog_normalization_profile_update(profile_id: int, payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = upsert_normalization_profile(session, profile_id=profile_id, payload=payload)
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
        return result


@app.delete("/api/catalog-normalization/profiles/{profile_id}")
async def catalog_normalization_profile_delete(profile_id: int):
    with get_session() as session:
        try:
            result = delete_normalization_profile(session, profile_id)
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        session.commit()
        return result


@app.get("/api/catalog-normalization/rules")
async def catalog_normalization_rules(
    profile_id: Optional[int] = Query(None),
    status: Optional[str] = Query(None),
    limit: int = Query(200, ge=1, le=500),
):
    with get_session() as session:
        return list_normalization_rules(session, profile_id=profile_id, status=status, limit=limit)


@app.post("/api/catalog-normalization/rules")
async def catalog_normalization_rule_create(payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = upsert_normalization_rule(session, payload=payload)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
        return result


@app.put("/api/catalog-normalization/rules/{rule_id}")
async def catalog_normalization_rule_update(rule_id: int, payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = upsert_normalization_rule(session, rule_id=rule_id, payload=payload)
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
        return result


@app.delete("/api/catalog-normalization/rules/{rule_id}")
async def catalog_normalization_rule_delete(rule_id: int):
    with get_session() as session:
        try:
            result = delete_normalization_rule(session, rule_id)
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        session.commit()
        return result


@app.get("/api/catalog-normalization/value-aliases")
async def catalog_normalization_value_aliases(
    entity_kind: Optional[str] = Query(None),
    field_name: Optional[str] = Query(None),
    limit: int = Query(200, ge=1, le=500),
):
    with get_session() as session:
        return list_value_aliases(session, entity_kind=entity_kind, field_name=field_name, limit=limit)


@app.post("/api/catalog-normalization/value-aliases")
async def catalog_normalization_value_alias_create(payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = upsert_value_alias(session, payload=payload)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
        return result


@app.put("/api/catalog-normalization/value-aliases/{alias_id}")
async def catalog_normalization_value_alias_update(alias_id: int, payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = upsert_value_alias(session, alias_id=alias_id, payload=payload)
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
        return result


@app.delete("/api/catalog-normalization/value-aliases/{alias_id}")
async def catalog_normalization_value_alias_delete(alias_id: int):
    with get_session() as session:
        try:
            result = delete_value_alias(session, alias_id)
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        session.commit()
        return result


@app.get("/api/catalog-normalization/collection-commerce")
async def catalog_normalization_collection_commerce(
    channel_code: Optional[str] = Query(None),
    connection_id: Optional[int] = Query(None),
    collection_name: Optional[str] = Query(None),
    limit: int = Query(200, ge=1, le=500),
):
    with get_session() as session:
        return list_collection_commerce_profiles(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            collection_name=collection_name,
            limit=limit,
        )


@app.post("/api/catalog-normalization/collection-commerce")
async def catalog_normalization_collection_commerce_create(payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = upsert_collection_commerce_profile(session, payload=payload)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
        return result


@app.put("/api/catalog-normalization/collection-commerce/{profile_id}")
async def catalog_normalization_collection_commerce_update(profile_id: int, payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = upsert_collection_commerce_profile(session, profile_id=profile_id, payload=payload)
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
        return result


@app.delete("/api/catalog-normalization/collection-commerce/{profile_id}")
async def catalog_normalization_collection_commerce_delete(profile_id: int):
    with get_session() as session:
        try:
            result = delete_collection_commerce_profile(session, profile_id)
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        session.commit()
        return result


@app.get("/api/catalog-normalization/locations")
async def catalog_normalization_locations():
    with get_session() as session:
        return {"locations": active_location_directory(session)}


@app.post("/api/catalog-normalization/location-resolution")
async def catalog_normalization_location_resolution(payload: dict = Body(...)):
    with get_session() as session:
        try:
            result = collection_location_context(
                session,
                category_l1=payload.get("category_l1"),
                collection_name=payload.get("collection_name") or payload.get("collection"),
                preferred_location_code=payload.get("preferred_location_code"),
                user_latitude=float(payload["user_latitude"]) if payload.get("user_latitude") not in (None, "") else None,
                user_longitude=float(payload["user_longitude"]) if payload.get("user_longitude") not in (None, "") else None,
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return result


@app.get("/api/catalog-review-queue")
async def catalog_review_queue(
    status: Optional[str] = Query("open"),
    severity: Optional[str] = Query(None),
    sku: Optional[str] = Query(None),
    limit: int = Query(100, ge=1, le=500),
    offset: int = Query(0, ge=0),
):
    with get_session() as session:
        return list_review_queue(
            session,
            status=status,
            severity=severity,
            sku=sku,
            limit=limit,
            offset=offset,
        )


@app.post("/api/catalog-review-queue/{item_id}/resolve")
async def catalog_review_queue_resolve(item_id: int, payload: dict = Body(default={})):
    with get_session() as session:
        try:
            result = resolve_review_item(
                session,
                item_id,
                resolution=str(payload.get("resolution") or "accepted"),
                notes=payload.get("notes"),
                proposed_value=payload.get("proposed_value"),
            )
        except KeyError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        session.commit()
        return result


@app.get("/api/channel-catalog-policies")
async def channel_catalog_policies(channel_code: Optional[str] = Query(None)):
    with get_session() as session:
        return list_catalog_policies(session, channel_code=channel_code)


@app.put("/api/channel-catalog-policies/{channel_code}")
async def channel_catalog_policy_upsert(channel_code: str, payload: dict = Body(...)):
    connection_id = payload.get("connection_id")
    if connection_id is not None and str(connection_id).strip() != "":
        connection_id = int(connection_id)
    else:
        connection_id = None
    with get_session() as session:
        result = upsert_catalog_policy(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            payload=payload,
        )
        session.commit()
        return result


@app.get("/api/channel-projections/{channel_code}/status")
async def channel_projection_status(
    channel_code: str,
    connection_id: Optional[int] = Query(None),
):
    with get_session() as session:
        return projection_status(session, channel_code=channel_code, connection_id=connection_id)


@app.get("/api/channel-projections/{channel_code}/rows")
async def channel_projection_rows(
    channel_code: str,
    connection_id: Optional[int] = Query(None),
    status: Optional[str] = Query(None),
    sku: Optional[str] = Query(None),
    limit: int = Query(50, ge=1, le=200),
    offset: int = Query(0, ge=0),
):
    with get_session() as session:
        return list_projection_rows(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            status=status,
            sku=sku,
            limit=limit,
            offset=offset,
        )


@app.post("/api/channel-projections/{channel_code}/inspect")
async def channel_projection_inspect(channel_code: str, payload: dict = Body(default={})):
    connection_id = payload.get("connection_id")
    if connection_id is not None and str(connection_id).strip() != "":
        connection_id = int(connection_id)
    else:
        connection_id = None
    skus = payload.get("skus") or None
    apply = _parse_bool_param(payload.get("apply"), default=False)
    with get_session() as session:
        result = inspect_projection(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            skus=skus,
            apply=apply,
        )
        if apply:
            session.commit()
        return result


@app.post("/api/channel-projections/{channel_code}/rebuild")
async def channel_projection_rebuild(channel_code: str, payload: dict = Body(default={})):
    connection_id = payload.get("connection_id")
    if connection_id is not None and str(connection_id).strip() != "":
        connection_id = int(connection_id)
    else:
        connection_id = None
    skus = payload.get("skus") or None
    only_assigned = _parse_bool_param(payload.get("only_assigned"), default=True)
    replace_scope = _parse_bool_param(payload.get("replace_scope"), default=False)
    with get_session() as session:
        result = rebuild_projection(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            skus=skus,
            only_assigned=only_assigned,
            replace_scope=replace_scope,
        )
        session.commit()
        return result


@app.post("/api/variation-builder/preview")
async def variation_builder_preview(payload: dict = Body(...)):
    skus = payload.get("skus") or []
    group_attrs = payload.get("group_attrs") or None
    option_attrs = payload.get("option_attrs") or None
    parent_suffix = str(payload.get("parent_suffix") or "CONFIG")
    min_children = int(payload.get("min_children") or 2)
    option_axis_count = payload.get("option_axis_count")
    if option_axis_count is not None and str(option_axis_count).strip() != "":
        option_axis_count = int(option_axis_count)
    else:
        option_axis_count = None
    exclude_linked_skus = bool(payload.get("exclude_linked_skus", False))
    channel_code = str(payload.get("channel_code") or "magento")
    channel_codes = payload.get("channel_codes") or None
    connection_id = payload.get("connection_id")
    if connection_id is not None and str(connection_id).strip() != "":
        connection_id = int(connection_id)
    else:
        connection_id = None
    connection_ids = payload.get("connection_ids") or None
    with get_session() as session:
        return preview_variation_plan(
            session,
            skus=skus,
            group_attrs=group_attrs,
            option_attrs=option_attrs,
            parent_suffix=parent_suffix,
            min_children=min_children,
            option_axis_count=option_axis_count,
            exclude_linked_skus=exclude_linked_skus,
            channel_code=channel_code,
            channel_codes=channel_codes,
            connection_id=connection_id,
            connection_ids=connection_ids,
        )


@app.post("/api/variation-builder/suggest")
async def variation_builder_suggest(payload: dict = Body(default={})):
    group_attrs = payload.get("group_attrs") or None
    option_attrs = payload.get("option_attrs") or None
    parent_suffix = str(payload.get("parent_suffix") or "CONFIG")
    min_children = int(payload.get("min_children") or 2)
    option_axis_count = payload.get("option_axis_count")
    if option_axis_count is not None and str(option_axis_count).strip() != "":
        option_axis_count = int(option_axis_count)
    else:
        option_axis_count = None
    exclude_linked_skus = bool(payload.get("exclude_linked_skus", False))
    limit = int(payload.get("limit") or 10000)
    channel_code = str(payload.get("channel_code") or "magento")
    channel_codes = payload.get("channel_codes") or None
    connection_id = payload.get("connection_id")
    if connection_id is not None and str(connection_id).strip() != "":
        connection_id = int(connection_id)
    else:
        connection_id = None
    connection_ids = payload.get("connection_ids") or None
    with get_session() as session:
        return suggest_variation_plan(
            session,
            category_l1=payload.get("category_l1", "Kitchen Cabinets"),
            product_family=payload.get("product_family"),
            collection=payload.get("collection"),
            assembly_type=payload.get("assembly_type", "Assembled"),
            search=payload.get("search"),
            master_filters=payload.get("master_filters"),
            group_attrs=group_attrs,
            option_attrs=option_attrs,
            parent_suffix=parent_suffix,
            min_children=min_children,
            option_axis_count=option_axis_count,
            exclude_linked_skus=exclude_linked_skus,
            limit=limit,
            channel_code=channel_code,
            channel_codes=channel_codes,
            connection_id=connection_id,
            connection_ids=connection_ids,
        )


@app.post("/api/variation-builder/commit")
async def variation_builder_commit(payload: dict = Body(...)):
    groups = payload.get("groups") or []
    source_label = str(payload.get("source_label") or "variation_builder")
    replace_existing = bool(payload.get("replace_existing", False))
    assign_channels = payload.get("assign_channels")
    if assign_channels is None:
        assign_channels = ["magento"]
    elif isinstance(assign_channels, str):
        assign_channels = [assign_channels]
    mapping_connection_ids = payload.get("mapping_connection_ids") or {}
    create_identity_mappings = bool(payload.get("create_identity_mappings", True))
    with get_session() as session:
        result = commit_variation_plan(
            session,
            groups=groups,
            source_label=source_label,
            replace_existing=replace_existing,
            assign_channels=assign_channels,
            mapping_connection_ids=mapping_connection_ids,
            create_identity_mappings=create_identity_mappings,
        )
        session.commit()
        return result


@app.get("/api/dashboard/summary")
async def dashboard_summary():
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from sqlalchemy import func, select
    from db.models import ChannelJob, ChannelPublishState, IngestRun, MagentoSyncJob, MasterProduct, MasterProductOverride
    from db.connection_stats import build_connection_dashboard_row

    since = datetime.utcnow() - timedelta(hours=24)
    with get_session() as session:
        connections = _all_compat_connections(session)
        master_total = session.scalar(select(func.count()).select_from(MasterProduct)) or 0
        master_active = session.scalar(
            select(func.count()).select_from(MasterProduct).where(MasterProduct.is_active.is_(True))
        ) or 0
        seo_total = session.scalar(
            select(func.count())
            .select_from(MasterProductOverride)
            .where(MasterProductOverride.source_label == "product_seo")
        ) or 0
        magento_success_24h = session.scalar(
            select(func.count())
            .select_from(MagentoSyncJob)
            .where(MagentoSyncJob.finished_at >= since)
            .where(MagentoSyncJob.status.in_(["completed", "done", "success"]))
        ) or 0
        magento_failed_24h = session.scalar(
            select(func.count())
            .select_from(MagentoSyncJob)
            .where(MagentoSyncJob.finished_at >= since)
            .where(MagentoSyncJob.status == "failed")
        ) or 0
        magento_in_flight = session.scalar(
            select(func.count())
            .select_from(MagentoSyncJob)
            .where(MagentoSyncJob.status.in_(["started", "running", "queued"]))
        ) or 0
        channel_success_24h = session.scalar(
            select(func.count())
            .select_from(ChannelJob)
            .where(ChannelJob.finished_at >= since)
            .where(ChannelJob.status == "completed")
        ) or 0
        channel_failed_24h = session.scalar(
            select(func.count())
            .select_from(ChannelJob)
            .where(ChannelJob.finished_at >= since)
            .where(ChannelJob.status == "failed")
        ) or 0
        channel_in_flight = session.scalar(
            select(func.count())
            .select_from(ChannelJob)
            .where(ChannelJob.status.in_(["queued", "running", "delegated"]))
        ) or 0
        last_master = session.scalar(
            select(IngestRun.finished_at)
            .where(IngestRun.source.in_(["master_sku", "master_sku_reprocess"]))
            .order_by(IngestRun.finished_at.desc().nullslast(), IngestRun.started_at.desc())
            .limit(1)
        )
        by_connection = [build_connection_dashboard_row(session, conn) for conn in connections]
    return {
        "master_products": {"total": master_total, "active": master_active},
        "product_seo": {"total": seo_total},
        "recent_jobs": {
            "success_24h": magento_success_24h + channel_success_24h,
            "failed_24h": magento_failed_24h + channel_failed_24h,
            "in_flight": magento_in_flight + channel_in_flight,
            "magento_sync_jobs": {
                "success_24h": magento_success_24h,
                "failed_24h": magento_failed_24h,
                "in_flight": magento_in_flight,
            },
            "channel_jobs": {
                "success_24h": channel_success_24h,
                "failed_24h": channel_failed_24h,
                "in_flight": channel_in_flight,
            },
        },
        "ingest_runs": {"last_master_import_at": _dt(last_master), "last_seo_import_at": None},
        "by_connection": by_connection,
    }


@app.get("/api/dashboard/import-pages")
async def dashboard_import_pages():
    """Navigation metadata for master data / image import screens (PLytixMageDash)."""
    db_cfg = load_db_config()
    image_summary: Dict[str, Any] = {}
    if db_cfg.enabled:
        with get_session() as session:
            from db.master_product_images import master_image_summary

            image_summary = master_image_summary(session)
    return {
        "pages": [
            {
                "id": "master-skus",
                "title": "Master Data",
                "description": "Import canonical master SKU CSV (products, attributes, prices, locations).",
                "nav_label": "Master Data",
                "ui_path": "#/catalog",
                "endpoints": {
                    "upload_csv": "POST /api/master-skus/import/csv",
                    "compat_upload": "POST /api/ingest/runs + PUT upload + POST process (import_type=master)",
                    "validation": "GET /api/master-skus/validation",
                    "reprocess": "POST /api/master-skus/reprocess",
                },
            },
            {
                "id": "master-images",
                "title": "Product Images",
                "description": "Import Name+URL image CSV; filenames match master SKUs by prefix.",
                "nav_label": "Images",
                "ui_path": "#/catalog",
                "endpoints": {
                    "upload_csv": "POST /api/master-product-images/import-csv",
                    "compat_upload": "POST /api/ingest/runs + PUT upload + POST process (import_type=images)",
                    "summary": "GET /api/master-product-images/summary",
                },
                "summary": image_summary,
            },
            {
                "id": "photo-assets-by-sku",
                "title": "Photo Assets By SKU",
                "description": (
                    "Import wide Photo_Assets_By_SKU CSV (Image_Default/Open/Closed/Generic + "
                    "Collection_Hero/Vignette). Optionally fan vignettes out to style family peers."
                ),
                "nav_label": "Photo Assets",
                "ui_path": "#/catalog",
                "endpoints": {
                    "upload_csv": "POST /api/master-product-images/import-photo-assets-csv",
                    "summary": "GET /api/master-product-images/summary",
                },
                "summary": image_summary,
            },
            {
                "id": "product-seo",
                "title": "SEO Overrides",
                "description": "Import SEO title/description overrides that win over master data.",
                "nav_label": "SEO Overrides",
                "ui_path": "#/catalog",
                "endpoints": {
                    "upload_csv": "POST /api/master-overrides/csv",
                    "compat_upload": "POST /api/ingest/runs + PUT upload + POST process (import_type=seo)",
                },
            },
            {
                "id": "push-images",
                "title": "Push Images Only",
                "description": "Upload gallery images to linked Magento/Shopify SKUs without changing attributes.",
                "nav_label": "Push Images",
                "ui_path": "#/publish",
                "endpoints": {
                    "preview": "GET /api/channel-pipelines/{channel}/images/preview",
                    "push_magento": "POST /api/channel-pipelines/magento/push-images?connection_id=...",
                    "push_shopify": "POST /api/channel-pipelines/shopify/push-images?connection_id=...",
                },
            },
        ]
    }


@app.get("/api/dashboard/nav")
async def dashboard_nav():
    """Sidebar navigation metadata for PLytixMageDash (hash routes)."""
    return {
        "sections": [
            {
                "title": "Overview",
                "items": [{"id": "home", "label": "Dashboard", "path": "#/dashboard"}],
            },
            {
                "title": "Catalog Import",
                "items": [
                    {"id": "catalog", "label": "Catalog Import", "path": "#/catalog"},
                ],
            },
            {
                "title": "Normalization",
                "items": [
                    {"id": "normalization", "label": "Normalization", "path": "#/normalization"},
                ],
            },
            {
                "title": "Publish",
                "items": [
                    {"id": "publish", "label": "Publish", "path": "#/publish"},
                ],
            },
            {
                "title": "Master catalog",
                "items": [
                    {"id": "taxonomies", "label": "Taxonomies", "path": "#/taxonomies"},
                    {"id": "products-master", "label": "Master Products", "path": "#/products/master"},
                ],
            },
        ]
    }


# ----------------------------
# Dashboard taxonomy / PLP-PDP assignment workspace
# ----------------------------


@app.get("/api/dashboard/taxonomy-workspace")
async def dashboard_taxonomy_workspace(
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    search: Optional[str] = Query(None),
):
    """Dashboard shell: collections, categories, listing paths, URL rules, assignment counts."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.taxonomy_workspace import build_taxonomy_workspace

    with get_session() as session:
        magento_native = (
            _resolve_native_connection_id(session, magento_connection_id)
            if magento_connection_id
            else _default_native_connection_id(session, "magento")
        )
        shopify_native = (
            _resolve_native_connection_id(session, shopify_connection_id)
            if shopify_connection_id
            else _default_native_connection_id(session, "shopify")
        )
        workspace = build_taxonomy_workspace(
            session,
            magento_connection_id=magento_native,
            shopify_connection_id=shopify_native,
            search=search,
        )
    return {"status": "ok", **workspace}


@app.get("/api/dashboard/taxonomy-workspace/skus")
async def dashboard_taxonomy_workspace_skus(
    channel_code: Optional[str] = Query(None),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
    category_l1: Optional[str] = Query(None),
    category_l2: Optional[str] = Query(None),
    category_l3: Optional[str] = Query(None),
    collection: Optional[str] = Query(None),
    product_family: Optional[str] = Query(None),
    search: Optional[str] = Query(None),
    master_filters: Optional[str] = Query(None, description="JSON array: [{code,value,match}]"),
    without_listing_paths: bool = Query(False),
    without_magento: bool = Query(False),
    without_shopify: bool = Query(False),
    limit: int = Query(100, ge=1, le=500),
    offset: int = Query(0, ge=0),
):
    """Paginated SKU grid for assignment UI with PDP + PLP URL previews."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.taxonomy_workspace import build_workspace_sku_rows

    try:
        with get_session() as session:
            magento_native = (
                _resolve_native_connection_id(session, magento_connection_id)
                if magento_connection_id
                else _default_native_connection_id(session, "magento")
            )
            shopify_native = (
                _resolve_native_connection_id(session, shopify_connection_id)
                if shopify_connection_id
                else _default_native_connection_id(session, "shopify")
            )
            result = build_workspace_sku_rows(
                session,
                channel_code=channel_code,
                magento_connection_id=magento_native,
                shopify_connection_id=shopify_native,
                only_assigned=only_assigned,
                category_l1=category_l1,
                category_l2=category_l2,
                category_l3=category_l3,
                collection=collection,
                product_family=product_family,
                search=search,
                master_filters=master_filters,
                without_listing_paths=without_listing_paths,
                without_magento=without_magento,
                without_shopify=without_shopify,
                limit=limit,
                offset=offset,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/dashboard/taxonomy-workspace/assign")
async def dashboard_taxonomy_workspace_assign(payload: dict = Body(...)):
    """Unified assign action for dashboard: direct taxonomy and/or PLP listing paths."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_listing_path import bulk_assign_listing_paths
    from db.product_channel_taxonomy import bulk_assign_sku_taxonomy

    channel_code = str(payload.get("channel_code") or "").strip()
    connection_id = payload.get("connection_id")
    dry_run = bool(payload.get("dry_run", False))
    filters = {
        "skus": payload.get("skus"),
        "only_assigned": payload.get("only_assigned", True),
        "category_l1": payload.get("category_l1"),
        "category_l2": payload.get("category_l2"),
        "category_l3": payload.get("category_l3"),
        "collection": payload.get("collection"),
        "product_family": payload.get("product_family"),
        "search": payload.get("search"),
        "master_filters": payload.get("master_filters"),
        "limit": payload.get("limit"),
    }
    result: dict = {"status": "ok"}

    try:
        with get_session() as session:
            native_id = _resolve_native_connection_id(session, connection_id) if connection_id else None

            if payload.get("remote_ids"):
                result["taxonomy"] = bulk_assign_sku_taxonomy(
                    session,
                    channel_code=channel_code,
                    remote_ids=payload.get("remote_ids") or [],
                    taxonomy_kind=str(payload.get("taxonomy_kind") or "category"),
                    connection_id=native_id,
                    remote_path=payload.get("remote_path"),
                    replace_existing=bool(payload.get("replace_existing", False)),
                    dry_run=dry_run,
                    **filters,
                )

            if payload.get("path_slugs"):
                result["listing_paths"] = bulk_assign_listing_paths(
                    session,
                    path_slugs=payload.get("path_slugs") or [],
                    dry_run=dry_run,
                    replace_existing=bool(payload.get("replace_listing_paths", False)),
                    **filters,
                )

            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return result


@app.get("/api/dashboard/listing-paths")
async def dashboard_listing_paths(
    search: Optional[str] = Query(None),
    path_kind: Optional[str] = Query(None),
    hub_path_slug: Optional[str] = Query(None),
    limit: Optional[int] = Query(500),
):
    from db.channel_listing_path import list_listing_paths

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = list_listing_paths(
            session,
            search=search,
            path_kind=path_kind,
            hub_path_slug=hub_path_slug,
            limit=limit,
        )
    return {"status": "ok", "count": len(rows), "listing_paths": rows}


@app.post("/api/dashboard/listing-paths")
async def dashboard_listing_paths_upsert(payload: dict = Body(...)):
    from db.channel_listing_path import upsert_listing_path, upsert_listing_path_target

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    try:
        with get_session() as session:
            row = upsert_listing_path(
                session,
                path_slug=str(payload.get("path_slug") or ""),
                title=str(payload.get("title") or ""),
                path_kind=payload.get("path_kind"),
                parent_path_slug=payload.get("parent_path_slug"),
                hub_path_slug=payload.get("hub_path_slug"),
                facet_terms=payload.get("facet_terms"),
                shopping_facet_attribute=payload.get("shopping_facet_attribute"),
                notes=payload.get("notes"),
            )
            for target in payload.get("targets") or []:
                upsert_listing_path_target(
                    session,
                    path_slug=row["path_slug"],
                    channel_code=str(target.get("channel_code") or ""),
                    remote_id=str(target.get("remote_id") or ""),
                    taxonomy_kind=str(target.get("taxonomy_kind") or "category"),
                    connection_id=target.get("connection_id"),
                    remote_path=target.get("remote_path"),
                )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", "listing_path": row}


@app.get("/api/dashboard/listing-intersections/form-options")
async def dashboard_listing_intersections_form_options(
    hub_path_slug: Optional[str] = Query(None),
    facet_attribute: Optional[str] = Query(None),
):
    from db.listing_intersections import intersection_form_options

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        data = intersection_form_options(
            session,
            hub_path_slug=hub_path_slug,
            facet_attribute=facet_attribute,
        )
    return {"status": "ok", **data}


@app.get("/api/dashboard/listing-intersections")
async def dashboard_listing_intersections(
    base_path_slug: Optional[str] = Query(None),
    group_path_slug: Optional[str] = Query(None),
    is_active: bool = Query(True),
):
    from db.listing_intersections import list_listing_intersections

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = list_listing_intersections(
            session,
            base_path_slug=base_path_slug,
            group_path_slug=group_path_slug,
            is_active=is_active,
        )
    return {"status": "ok", "count": len(rows), "intersections": rows}


@app.post("/api/dashboard/listing-intersections")
async def dashboard_listing_intersection_upsert(payload: dict = Body(...)):
    from db.listing_intersections import upsert_listing_intersection_rule

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    try:
        with get_session() as session:
            row = upsert_listing_intersection_rule(
                session,
                base_path_slug=str(payload.get("base_path_slug") or ""),
                group_path_slug=str(payload.get("group_path_slug") or ""),
                group_kind=str(payload.get("group_kind") or "collection"),
                facet_attribute=str(payload.get("facet_attribute") or ""),
                facet_label=str(payload.get("facet_label") or ""),
                facet_value=payload.get("facet_value"),
                facet_slug=payload.get("facet_slug"),
                title=payload.get("title"),
                target_path_slug=payload.get("target_path_slug"),
                magento_category_id=payload.get("magento_category_id"),
                category_filter_path_slug=payload.get("category_filter_path_slug")
                or payload.get("filter_category_path"),
                magento_filter_category_id=payload.get("magento_filter_category_id")
                or payload.get("filter_category_id"),
                shopify_collection_id=payload.get("shopify_collection_id"),
                shopify_handle=payload.get("shopify_handle"),
                connection_id=payload.get("connection_id"),
                is_indexable=bool(payload.get("is_indexable", True)),
                notes=payload.get("notes"),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "intersection": row}


@app.post("/api/dashboard/listing-intersections/materialize")
async def dashboard_listing_intersections_materialize(payload: dict = Body(...)):
    from db.listing_intersections import materialize_listing_intersections
    from db.channel_listing_path import list_listing_paths

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    try:
        with get_session() as session:
            base_path_slug = str(payload.get("base_path_slug") or "")
            facet_attribute = str(payload.get("facet_attribute") or "").strip()
            if not facet_attribute and base_path_slug:
                hub_paths = list_listing_paths(session, search=base_path_slug, limit=50)
                hub = next((row for row in hub_paths if row.get("path_slug") == base_path_slug), None)
                facet_attribute = str((hub or {}).get("shopping_facet_attribute") or "").strip()
            if not facet_attribute:
                raise ValueError("facet_attribute is required or set shopping_facet_attribute on the hub listing path")
            # Start Shopping / taxonomy hubs default to category layered filters.
            use_category_filters = payload.get("use_category_filters")
            if use_category_filters is None:
                use_category_filters = True
            rows = materialize_listing_intersections(
                session,
                base_path_slug=base_path_slug,
                group_path_slug=str(payload.get("group_path_slug") or ""),
                facet_attribute=facet_attribute,
                options=payload.get("options") or [],
                group_kind=str(payload.get("group_kind") or "collection"),
                target_path_slug=payload.get("target_path_slug"),
                magento_category_id=payload.get("magento_category_id"),
                shopify_collection_id=payload.get("shopify_collection_id"),
                shopify_handle_prefix=payload.get("shopify_handle_prefix"),
                connection_id=payload.get("connection_id"),
                use_category_filters=bool(use_category_filters),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "count": len(rows), "intersections": rows}


@app.post("/api/dashboard/listing-intersections/sync-shopping")
async def dashboard_listing_intersections_sync_shopping(payload: dict = Body(...)):
    """Materialize two-level Start Shopping intersections (category filter_type) for a collection."""
    from channel.url_canonical import normalize_plp_path
    from db.collection_landing_pages import get_collection_landing_page
    from db.listing_intersections import (
        list_listing_intersections,
        shopping_intersections_for_collection,
        sync_shopping_intersections_for_collection,
    )

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    group_path = normalize_plp_path(str(payload.get("group_path_slug") or payload.get("path_slug") or ""))
    if not group_path:
        raise HTTPException(status_code=400, detail="group_path_slug is required")
    try:
        with get_session() as session:
            page = get_collection_landing_page(session, group_path)
            collection_row = page or {
                "path_slug": group_path,
                "category_l1": group_path.split("/")[0].replace("-", " ").title(),
                "collection": group_path.split("/")[-1].replace("-", " ").title(),
            }
            rows = sync_shopping_intersections_for_collection(
                session,
                collection_row,
                connection_id=payload.get("connection_id"),
                facet_attribute=payload.get("facet_attribute"),
                magento_category_id=payload.get("magento_category_id"),
                shopify_collection_id=payload.get("shopify_collection_id"),
                shopify_handle=payload.get("shopify_handle"),
            )
            session.commit()
            shopping = shopping_intersections_for_collection(session, group_path)
            listed = list_listing_intersections(
                session,
                base_path_slug=payload.get("base_path_slug") or group_path.split("/")[0],
                group_path_slug=group_path,
                is_active=True,
                include_nested=True,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {
        "status": "ok",
        "count": len(rows),
        "intersections": listed or rows,
        "shopping_intersections": shopping,
    }


@app.post("/api/dashboard/listing-paths/csv")
async def dashboard_listing_paths_import_csv(file: UploadFile = File(...)):
    from db.channel_listing_path import import_listing_paths_csv

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}") from exc
    with get_session() as session:
        stats = import_listing_paths_csv(session, df)
        session.commit()
    return {"status": "ok", **stats}


@app.post("/api/dashboard/listing-paths/unassign")
async def dashboard_listing_paths_unassign(payload: dict = Body(...)):
    """Deactivate PLP listing-path assignments for filtered or explicit SKUs."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_listing_path import bulk_remove_listing_paths

    dry_run = bool(payload.get("dry_run", False))
    try:
        with get_session() as session:
            result = bulk_remove_listing_paths(
                session,
                path_slugs=payload.get("path_slugs") or [],
                skus=payload.get("skus"),
                only_assigned=payload.get("only_assigned", False),
                category_l1=payload.get("category_l1"),
                category_l2=payload.get("category_l2"),
                category_l3=payload.get("category_l3"),
                collection=payload.get("collection"),
                product_family=payload.get("product_family"),
                search=payload.get("search"),
                master_filters=payload.get("master_filters"),
                limit=payload.get("limit"),
                dry_run=dry_run,
            )
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/dashboard/listing-paths/prune-stale")
async def dashboard_listing_paths_prune_stale(payload: dict = Body(...)):
    """Deactivate stale PLP assignments by comparing assigned paths vs current canonical taxonomy."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.stale_listing_path_cleanup import prune_stale_listing_paths

    dry_run = bool(payload.get("dry_run", True))
    try:
        with get_session() as session:
            result = prune_stale_listing_paths(
                session,
                skus=payload.get("skus"),
                search=payload.get("search"),
                category_l1=payload.get("category_l1"),
                category_l2=payload.get("category_l2"),
                category_l3=payload.get("category_l3"),
                collection=payload.get("collection"),
                product_family=payload.get("product_family"),
                master_filters=payload.get("master_filters"),
                limit=payload.get("limit"),
                rta_only=bool(payload.get("rta_only", False)),
                shared_only=bool(payload.get("shared_only", False)),
                sku_prefixes=payload.get("sku_prefixes"),
                all_products=bool(payload.get("all_products", False)),
                prune_channel_taxonomy=bool(payload.get("prune_channel_taxonomy", True)),
                dry_run=dry_run,
            )
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/dashboard/master-products/switch-taxonomy")
async def dashboard_master_products_switch_taxonomy(payload: dict = Body(...)):
    """Move SKUs from one master taxonomy node to another."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_taxonomy_workspace import switch_skus_taxonomy_node

    skus = payload.get("skus") or []
    to_node_id = payload.get("to_node_id")
    if not skus:
        raise HTTPException(status_code=400, detail="skus is required")
    if to_node_id is None:
        raise HTTPException(status_code=400, detail="to_node_id is required")
    try:
        with get_session() as session:
            magento_native = (
                _resolve_native_connection_id(session, payload.get("magento_connection_id"))
                if payload.get("magento_connection_id")
                else _default_native_connection_id(session, "magento")
            )
            shopify_native = (
                _resolve_native_connection_id(session, payload.get("shopify_connection_id"))
                if payload.get("shopify_connection_id")
                else _default_native_connection_id(session, "shopify")
            )
            result = switch_skus_taxonomy_node(
                session,
                skus=skus,
                to_node_id=int(to_node_id),
                from_node_id=payload.get("from_node_id"),
                replace_existing=bool(payload.get("replace_existing", True)),
                dry_run=bool(payload.get("dry_run", False)),
                magento_connection_id=magento_native,
                shopify_connection_id=shopify_native,
            )
            if not result.get("dry_run"):
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


def _resolve_shopify_compat_connection(session, connection_id: Optional[int]) -> dict:
    """Resolve a Shopify compat connection row (explicit id or first active shop)."""
    if connection_id:
        row = _connection_row(session, connection_id)
        if row and row.get("channel_type") == "shopify":
            return row
        raise HTTPException(status_code=400, detail="Shopify connection not found")
    from db.compat_connections import compat_connection_id, default_native_connection_id

    native_id = default_native_connection_id(session, "shopify")
    if native_id is None:
        raise HTTPException(status_code=400, detail="No Shopify connection configured")
    row = _connection_row(session, compat_connection_id("shopify", native_id))
    if row is None:
        raise HTTPException(status_code=400, detail="Shopify connection not found")
    return row


def _resolve_channel_compat_connection(session, channel_code: str, connection_id: Optional[int]) -> dict:
    """Resolve a Magento or Shopify compat connection row (explicit id or default store)."""
    channel = str(channel_code or "").strip().lower()
    if channel not in {"magento", "shopify"}:
        raise HTTPException(status_code=400, detail="channel must be magento or shopify")
    if connection_id:
        row = _connection_row(session, connection_id)
        if row and row.get("channel_type") == channel:
            return row
        native = _resolve_native_connection_id(session, connection_id)
        if native is not None:
            row = _connection_row(session, _compat_id(channel, native))
            if row:
                return row
        raise HTTPException(status_code=400, detail=f"{channel} connection not found")
    native_id = _default_native_connection_id(session, channel)
    if native_id is None:
        raise HTTPException(status_code=400, detail=f"No {channel} connection configured")
    row = _connection_row(session, _compat_id(channel, native_id))
    if row is None:
        raise HTTPException(status_code=400, detail=f"{channel} connection not found")
    return row


def _primary_collection_job_connection(
    session,
    *,
    magento_connection_id: Optional[int] = None,
    shopify_connection_id: Optional[int] = None,
) -> dict:
    """Pick a channel connection row for collection jobs that span both channels."""
    for channel, conn_id in (("magento", magento_connection_id), ("shopify", shopify_connection_id)):
        if conn_id:
            return _resolve_channel_compat_connection(session, channel, conn_id)
    for channel in ("magento", "shopify"):
        native_id = _default_native_connection_id(session, channel)
        if native_id is not None:
            row = _connection_row(session, _compat_id(channel, native_id))
            if row:
                return row
    raise HTTPException(status_code=400, detail="No Magento or Shopify connection configured")


@app.post("/api/dashboard/master-products/push")
async def dashboard_master_products_push(payload: dict = Body(...)):
    """Create or update selected master SKUs on Magento or Shopify."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    channel = str(payload.get("channel_code") or "").strip().lower()
    if channel not in {"magento", "shopify"}:
        raise HTTPException(status_code=400, detail="channel_code must be magento or shopify")
    dry_run = bool(payload.get("dry_run", True))
    connection_id = payload.get("connection_id")
    force = bool(payload.get("force", False))

    skus = [str(s).strip() for s in (payload.get("skus") or []) if str(s).strip()]
    if not skus and payload.get("filters"):
        from db.taxonomy_workspace import resolve_workspace_filtered_skus

        filters = payload.get("filters") or {}
        with get_session() as session:
            magento_native = (
                _resolve_native_connection_id(session, filters.get("magento_connection_id"))
                if filters.get("magento_connection_id")
                else _default_native_connection_id(session, "magento")
            )
            shopify_native = (
                _resolve_native_connection_id(session, filters.get("shopify_connection_id"))
                if filters.get("shopify_connection_id")
                else _default_native_connection_id(session, "shopify")
            )
            skus = resolve_workspace_filtered_skus(
                session,
                magento_connection_id=magento_native,
                shopify_connection_id=shopify_native,
                only_assigned=bool(filters.get("only_assigned", False)),
                category_l1=filters.get("category_l1"),
                category_l2=filters.get("category_l2"),
                category_l3=filters.get("category_l3"),
                collection=filters.get("collection"),
                product_family=filters.get("product_family"),
                search=filters.get("search"),
                master_filters=filters.get("master_filters"),
                without_listing_paths=bool(filters.get("without_listing_paths", False)),
                without_magento=bool(filters.get("without_magento", False)),
                without_shopify=bool(filters.get("without_shopify", False)),
            )
    if not skus:
        raise HTTPException(status_code=400, detail="skus or filters is required")

    if channel == "magento":
        if not connection_id:
            raise HTTPException(status_code=400, detail="connection_id is required for Magento push")
        if dry_run:
            return {
                "status": "ok",
                "channel_code": channel,
                "sku_count": len(skus),
                "dry_run": True,
                "would_enqueue": True,
            }
        from datetime import datetime as _dt
        from db.channel_assignments import assign_skus
        from db.magento_repositories import SqlAlchemyMagentoSyncQueueRepository

        label = f"master-products-push-{_dt.utcnow().strftime('%Y%m%d-%H%M%S')}"
        options: Dict[str, Any] = {
            "dry_run": dry_run,
            "force_full_sync": force,
            "use_master_catalog": True,
            "limit_skus": skus,
            "expand_relations": False,
        }
        with get_session() as session:
            native_id = _resolve_native_connection_id(session, connection_id)
            assign_skus(session, skus=skus, channel_code="magento", reason="master_products_push")
            queue_id = SqlAlchemyMagentoSyncQueueRepository(session).enqueue(
                native_id,
                label,
                options=options,
                supersede_queued=False,
            )
            session.commit()
        return {
            "status": "queued" if not dry_run else "ok",
            "channel_code": channel,
            "queue_id": queue_id,
            "label": label,
            "sku_count": len(skus),
            "dry_run": dry_run,
        }

    if dry_run:
        return {
            "status": "ok",
            "channel_code": channel,
            "sku_count": len(skus),
            "dry_run": True,
            "would_enqueue": True,
        }

    import os

    from app.jobs.channel_jobs import enqueue_shopify_product_push_jobs_batched, job_to_dict
    from db.channel_assignments import assign_skus

    batch_size = max(1, int(os.getenv("MASTER_PRODUCTS_PUSH_BATCH_SIZE", "200")))

    with get_session() as session:
        connection = _resolve_shopify_compat_connection(session, connection_id)
        assign_skus(session, skus=skus, channel_code="shopify", reason="master_products_push")
        jobs = enqueue_shopify_product_push_jobs_batched(
            session,
            channel_connection_id=connection["id"],
            native_connection_id=connection["native_id"],
            channel_code=_channel_code_for_connection(connection),
            skus=skus,
            batch_size=batch_size,
            force=force,
            dry_run=False,
            shop_code=connection.get("store_code"),
            notes="master_products_push",
        )
        job_payloads = [job_to_dict(job) for job in jobs]
        session.commit()
    response = {
        "status": "queued",
        "channel_code": channel,
        "job_id": job_payloads[0]["id"],
        "job": job_payloads[0],
        "sku_count": len(skus),
        "dry_run": dry_run,
    }
    if len(job_payloads) > 1:
        response["batch_count"] = len(job_payloads)
        response["batch_size"] = batch_size
        response["jobs"] = [
            {
                "job_id": job["id"],
                "sku_count": len((job.get("result") or {}).get("_options", {}).get("skus") or []),
                "notes": job.get("notes"),
            }
            for job in job_payloads
        ]
    return response


@app.post("/api/dashboard/kitchen-packages/push")
async def dashboard_kitchen_packages_push(payload: dict = Body(...)):
    """Create or update kitchen package products on Magento and/or Shopify."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    raw_channels = payload.get("channels", ["magento", "shopify"])
    if isinstance(raw_channels, str):
        channels = [c.strip().lower() for c in raw_channels.split(",") if str(c).strip()]
    else:
        channels = [str(c).strip().lower() for c in (raw_channels or []) if str(c).strip()]
    if not channels:
        channels = ["magento", "shopify"]
    invalid_channels = sorted({channel for channel in channels if channel not in {"magento", "shopify"}})
    if invalid_channels:
        raise HTTPException(
            status_code=400,
            detail=f"channels must only include magento and/or shopify: {', '.join(invalid_channels)}",
        )

    dry_run = bool(payload.get("dry_run", True))
    file_path = payload.get("file_path") or payload.get("file")
    from_db = bool(payload.get("from_db", not bool(file_path)))

    collection_codes = payload.get("collection_codes")
    if collection_codes is None:
        single_code = payload.get("collection_code")
        if single_code is not None:
            collection_codes = [single_code]
    if isinstance(collection_codes, str):
        collection_codes = [collection_codes]
    collection_codes = [str(code).strip() for code in (collection_codes or []) if str(code).strip()]

    if not file_path and not from_db:
        raise HTTPException(status_code=400, detail="Provide file_path/file or set from_db=true")

    from app.jobs.push_kitchen_packages import run_push_kitchen_packages

    try:
        result = run_push_kitchen_packages(
            file_path=str(file_path).strip() if file_path else None,
            from_db=from_db,
            collection_codes=collection_codes or None,
            channels=channels,
            magento_connection_id=payload.get("magento_connection_id"),
            shopify_connection_id=payload.get("shopify_connection_id"),
            apply=not dry_run,
            upsert_master=bool(payload.get("upsert_master", True)),
            skip_missing_components=bool(payload.get("skip_missing_components", True)),
            require_components_exist=bool(payload.get("require_components_exist", False)),
            force_recreate=bool(payload.get("force_recreate", False)),
        )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    return {"status": "ok", **result}


@app.get("/api/dashboard/collections")
async def dashboard_collections(
    search: Optional[str] = Query(None),
    category_l1: Optional[str] = Query("Kitchen Cabinets"),
    include_inferred: bool = Query(True),
    limit: int = Query(500, ge=1, le=1000),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_pages import list_collection_landing_pages

    with get_session() as session:
        rows = list_collection_landing_pages(
            session,
            search=search,
            category_l1=category_l1,
            include_inferred=include_inferred,
            limit=limit,
        )
    return {"status": "ok", "count": len(rows), "collections": rows}


@app.get("/api/dashboard/collections/start-shopping-options")
async def dashboard_collection_start_shopping_options(
    path_slug: Optional[str] = Query(None),
    category_l1: Optional[str] = Query(None),
    parent_path_slug: Optional[str] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_pages import collection_start_shopping_options

    with get_session() as session:
        payload = collection_start_shopping_options(
            session,
            path_slug=path_slug,
            category_l1=category_l1,
            parent_path_slug=parent_path_slug,
        )
    return {"status": "ok", **payload}


@app.post("/api/dashboard/collections/start-shopping-config")
async def dashboard_collection_start_shopping_config_upsert(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_pages import apply_start_shopping_config_to_collections

    try:
        with get_session() as session:
            result = apply_start_shopping_config_to_collections(
                session,
                parent_path_slug=payload.get("parent_path_slug") or "",
                collection_path_slugs=list(payload.get("collection_path_slugs") or []),
                start_shopping_config=payload.get("start_shopping_config"),
                category_l1=payload.get("category_l1"),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/dashboard/collections/start-shopping-config/prune")
async def dashboard_collection_start_shopping_config_prune(payload: dict = Body(...)):
    """Drop/rewrite Start Shopping refs; optionally queue Magento+Shopify landing pushes."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.start_shopping_config_prune import (
        enqueue_start_shopping_outbound_pushes,
        prune_start_shopping_configs,
    )

    dry_run = bool(payload.get("dry_run", True))
    refresh_intersections = bool(payload.get("refresh_intersections", True))
    push_outbound = bool(payload.get("push_outbound", False))
    sync_products = bool(payload.get("sync_products", True))
    collection_path_slugs = payload.get("collection_path_slugs")
    if isinstance(collection_path_slugs, str):
        collection_path_slugs = [collection_path_slugs]
    path_rewrites = payload.get("path_rewrites")
    if path_rewrites is not None and not isinstance(path_rewrites, dict):
        raise HTTPException(status_code=400, detail="path_rewrites must be an object of old_slug -> new_slug|null")
    try:
        with get_session() as session:
            result = prune_start_shopping_configs(
                session,
                path_rewrites=path_rewrites,
                collection_path_slugs=list(collection_path_slugs or []) or None,
                hub_path_slug=payload.get("hub_path_slug") or payload.get("parent_path_slug"),
                dry_run=dry_run,
                refresh_intersections=refresh_intersections,
            )
            outbound = {
                "dry_run": dry_run,
                "queued": 0,
                "would_queue": 0,
                "jobs": [],
                "skipped_channels": [],
            }
            if push_outbound:
                connections = []
                for channel, key in (("magento", "magento_connection_id"), ("shopify", "shopify_connection_id")):
                    try:
                        connections.append(
                            _resolve_channel_compat_connection(session, channel, payload.get(key))
                        )
                    except HTTPException:
                        outbound["skipped_channels"].append(channel)
                outbound = enqueue_start_shopping_outbound_pushes(
                    session,
                    result.get("updated_path_slugs") or [],
                    connections=connections,
                    dry_run=dry_run,
                    sync_products=sync_products,
                    notes="start_shopping_fix_outbound",
                )
            result["push_outbound"] = push_outbound
            result["outbound"] = outbound
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/dashboard/collections/start-shopping-apply-skus")
async def dashboard_collection_start_shopping_apply_skus(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_pages import apply_start_shopping_to_master_skus

    collection_path_slugs = list(payload.get("collection_path_slugs") or [])
    dry_run = bool(payload.get("dry_run", False))
    try:
        with get_session() as session:
            result = apply_start_shopping_to_master_skus(
                session,
                collection_path_slugs=collection_path_slugs,
                dry_run=dry_run,
            )
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/dashboard/collections/start-shopping-provision-magento")
async def dashboard_collection_start_shopping_provision_magento(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_attribute_provision import provision_channel_attributes
    from db.channel_attribute_provision import repair_magento_select_attributes
    from db.collection_landing_pages import (
        START_SHOPPING_ATTRIBUTE_DEFINITIONS,
        ensure_start_shopping_magento_aliases,
        ensure_start_shopping_master_attributes,
    )
    from db.channel_remote_attributes import promote_attribute_to_options

    connection_id = payload.get("connection_id")
    if connection_id is None:
        raise HTTPException(status_code=400, detail="connection_id is required")
    dry_run = bool(payload.get("dry_run", False))
    recreate_text_attributes = bool(payload.get("recreate_text_attributes", False))

    with get_session() as session:
        master_attributes_created = ensure_start_shopping_master_attributes(session)
        magento_aliases_created = ensure_start_shopping_magento_aliases(session)
        attribute_codes = [code for code, _label in START_SHOPPING_ATTRIBUTE_DEFINITIONS]
        provision = provision_channel_attributes(
            session,
            "magento",
            connection_id=int(connection_id),
            canonical_codes=attribute_codes,
            dry_run=dry_run,
        )
        attribute_repairs = repair_magento_select_attributes(
            session,
            int(connection_id),
            canonical_codes=attribute_codes,
            dry_run=dry_run,
        ) if recreate_text_attributes else {
            "status": "skipped",
            "channel": "magento",
            "connection_id": int(connection_id),
            "reason": "recreate_text_attributes=false",
        }
        option_sync = {
            code: promote_attribute_to_options(
                session,
                channel_type="magento",
                connection_id=int(connection_id),
                channel_attribute_code=code,
                canonical_code=code,
                dry_run=dry_run,
            )
            for code in attribute_codes
        }
        if not dry_run:
            session.commit()
    status = "ok"
    nested_statuses = [str(provision.get("status") or "").lower(), str(attribute_repairs.get("status") or "").lower()]
    nested_statuses.extend(str((entry or {}).get("status") or "").lower() for entry in option_sync.values())
    if any(state in {"failed", "error"} for state in nested_statuses):
        status = "failed"
    elif any(
        state == "partial"
        or (isinstance(entry, dict) and entry.get("errors"))
        for state, entry in [
            (str(provision.get("status") or "").lower(), provision),
            (str(attribute_repairs.get("status") or "").lower(), attribute_repairs),
            *[(str((value or {}).get("status") or "").lower(), value) for value in option_sync.values()],
        ]
    ):
        status = "partial"
    return {
        "status": status,
        "connection_id": int(connection_id),
        "master_attributes_created": master_attributes_created,
        "magento_aliases_created": magento_aliases_created,
        "attribute_codes": attribute_codes,
        "provision": provision,
        "attribute_repairs": attribute_repairs,
        "option_sync": option_sync,
    }


@app.post("/api/dashboard/collections/start-shopping-push")
async def dashboard_collection_start_shopping_push(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.channel_jobs import enqueue_collection_push_job, job_to_dict
    from channel.url_canonical import normalize_plp_path

    collection_path_slugs = [
        normalize_plp_path(str(path or ""))
        for path in list(payload.get("collection_path_slugs") or [])
        if normalize_plp_path(str(path or ""))
    ]
    if not collection_path_slugs:
        raise HTTPException(status_code=400, detail="Select at least one collection to push.")

    try:
        with get_session() as session:
            connection = _resolve_channel_compat_connection(
                session,
                "magento",
                payload.get("connection_id"),
            )
            jobs = []
            for path_slug in collection_path_slugs:
                job = enqueue_collection_push_job(
                    session,
                    channel_connection_id=connection["id"],
                    native_connection_id=connection["native_id"],
                    channel_code=_channel_code_for_connection(connection),
                    channel_type=connection["channel_type"],
                    path_slug=path_slug,
                    category_l1=payload.get("category_l1"),
                    only_with_landing_content=True,
                    dry_run=False,
                    provision_missing_remote=bool(payload.get("provision_missing_remote", True)),
                    apply_listing_paths=bool(payload.get("apply_listing_paths", True)),
                    sync_products=bool(payload.get("sync_products", True)),
                    sync_shopping_icons=bool(payload.get("sync_shopping_icons", True)),
                    limit=1,
                    bootstrap_first=bool(payload.get("bootstrap_first", True)),
                    notes=f"start_shopping_push:{path_slug}",
                )
                jobs.append(job_to_dict(job))
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    return {
        "status": "queued",
        "channel": "magento",
        "count": len(jobs),
        "jobs": jobs,
        "job_ids": [row["id"] for row in jobs],
        "poll_urls": [f"/api/channel-jobs/{row['id']}" for row in jobs],
        "message": f"Queued {len(jobs)} Magento collection push job(s).",
    }


@app.post("/api/dashboard/collections/start-shopping-sync-products")
async def dashboard_collection_start_shopping_sync_products(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.channel_jobs import enqueue_start_shopping_magento_product_sync
    from channel.url_canonical import normalize_plp_path

    collection_path_slugs = [
        normalize_plp_path(str(path or ""))
        for path in list(payload.get("collection_path_slugs") or [])
        if normalize_plp_path(str(path or ""))
    ]
    category_l1 = payload.get("category_l1")
    if not collection_path_slugs and not str(category_l1 or "").strip():
        raise HTTPException(
            status_code=400,
            detail="Provide collection_path_slugs or category_l1.",
        )

    try:
        with get_session() as session:
            connection = _resolve_channel_compat_connection(
                session,
                "magento",
                payload.get("connection_id"),
            )
            result = enqueue_start_shopping_magento_product_sync(
                session,
                channel_connection_id=connection["id"],
                native_connection_id=connection["native_id"],
                collection_path_slugs=collection_path_slugs or None,
                category_l1=category_l1,
                dry_run=bool(payload.get("dry_run", False)),
                limit=int(payload.get("limit") or 200),
                notes=str(payload.get("notes") or "start_shopping_products_sync"),
                supersede_queued=bool(payload.get("supersede_queued", False)),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    queue_id = result.get("queue_id")
    return {
        "status": result.get("status"),
        "channel": "magento",
        "connection_id": int(payload.get("connection_id") or connection["id"]),
        **result,
        "poll_url": f"/api/magento/sync-queue/{queue_id}" if queue_id else None,
    }


@app.post("/api/dashboard/collections")
async def dashboard_collection_upsert(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_pages import upsert_collection_landing_page
    from db.collection_landing_pages import collection_path_slug as derive_collection_path_slug
    from db.collection_landing_pages import canonical_category, canonical_collection

    delegate = bool(payload.get("delegate") or payload.get("async"))
    if delegate:
        from app.jobs.master_taxonomy_enqueue import start_master_taxonomy_job

        category = canonical_category(payload.get("category_l1"))
        collection = canonical_collection(payload.get("collection") or payload.get("title"))
        path_slug = payload.get("path_slug") or derive_collection_path_slug(category, collection)
        job_id = start_master_taxonomy_job("collection_save", {"payload": dict(payload)})
        return {
            "status": "queued",
            "job_id": job_id,
            "poll_url": f"/api/master-taxonomy/jobs/{job_id}",
            "path_slug": path_slug,
            "message": "Collection save queued; poll poll_url until terminal.",
        }

    try:
        with get_session() as session:
            row = upsert_collection_landing_page(session, payload)
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "collection": row}


@app.delete("/api/dashboard/collections/{path_slug:path}")
async def dashboard_collection_delete(
    path_slug: str,
    cascade: bool = Query(False),
    purge_remote: bool = Query(False),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_delete import deactivate_taxonomy_node_by_path
    from db.master_taxonomy_remote_purge import resolve_channel_clients_for_purge

    try:
        with get_session() as session:
            magento_api = None
            shopify_client = None
            if purge_remote:
                magento_api, shopify_client = resolve_channel_clients_for_purge(
                    session,
                    magento_connection_id=magento_connection_id,
                    shopify_connection_id=shopify_connection_id,
                )
            result = deactivate_taxonomy_node_by_path(
                session,
                path_slug,
                cascade=cascade,
                note="deactivated from Collections dashboard",
                purge_remote=purge_remote,
                magento_api=magento_api,
                shopify_client=shopify_client,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "deactivated": True, **result}


@app.post("/api/dashboard/collections/apply")
async def dashboard_collection_apply(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_pages import apply_collection_to_skus

    dry_run = bool(payload.get("dry_run", True))
    try:
        with get_session() as session:
            result = apply_collection_to_skus(
                session,
                path_slug=payload.get("path_slug"),
                category_l1=payload.get("category_l1"),
                collection=payload.get("collection"),
                dry_run=dry_run,
                only_assigned=bool(payload.get("only_assigned", False)),
            )
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/dashboard/shared-sku-images/cleanup")
async def dashboard_shared_sku_images_cleanup(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.shared_sku_image_cleanup import (
        cleanup_shared_sku_collection_images,
        enqueue_shared_sku_image_cleanup_outbound,
    )

    def _requested_connection_ids(body: dict, singular_key: str, plural_key: str) -> List[Optional[int]]:
        plural = body.get(plural_key)
        values: List[Optional[int]] = []
        if isinstance(plural, list):
            values.extend(plural)
        elif plural is not None:
            values.append(plural)
        elif singular_key in body:
            values.append(body.get(singular_key))
        else:
            values.append(None)
        normalized: List[Optional[int]] = []
        seen = set()
        for raw in values:
            if raw is None or raw == "":
                if None not in seen:
                    seen.add(None)
                    normalized.append(None)
                continue
            clean = int(raw)
            if clean not in seen:
                seen.add(clean)
                normalized.append(clean)
        return normalized

    dry_run = bool(payload.get("dry_run", True))
    run_local_cleanup = bool(payload.get("run_local_cleanup", True))
    run_outbound_cleanup = bool(payload.get("run_outbound_cleanup", False))
    prune_magento_orphans = bool(payload.get("prune_magento_orphans", True))
    push_magento_images = bool(payload.get("push_magento_images", True))
    push_shopify_images = bool(payload.get("push_shopify_images", True))
    batch_size = int(payload.get("batch_size") or 250)

    try:
        with get_session() as session:
            local_result = cleanup_shared_sku_collection_images(
                session,
                master_skus=payload.get("master_skus") or payload.get("skus"),
                collection_path_slugs=payload.get("collection_path_slugs"),
                collection_codes=payload.get("collection_codes"),
                all_shared=bool(payload.get("all_shared", False)),
                dry_run=(not run_local_cleanup) or dry_run,
            )
            outbound_result = {
                "status": "ok",
                "dry_run": dry_run,
                "queued": 0,
                "would_queue": 0,
                "jobs": [],
                "skipped_channels": [],
                "message": "Outbound cleanup not requested.",
            }
            target_skus = list((local_result.get("scope") or {}).get("target_skus") or [])
            if run_outbound_cleanup:
                magento_connections = []
                if prune_magento_orphans or push_magento_images:
                    for requested_id in _requested_connection_ids(
                        payload,
                        "magento_connection_id",
                        "magento_connection_ids",
                    ):
                        try:
                            magento_connections.append(
                                _resolve_channel_compat_connection(session, "magento", requested_id)
                            )
                        except HTTPException:
                            continue
                shopify_connections = []
                if push_shopify_images:
                    for requested_id in _requested_connection_ids(
                        payload,
                        "shopify_connection_id",
                        "shopify_connection_ids",
                    ):
                        try:
                            shopify_connections.append(
                                _resolve_channel_compat_connection(session, "shopify", requested_id)
                            )
                        except HTTPException:
                            continue
                outbound_result = enqueue_shared_sku_image_cleanup_outbound(
                    session,
                    skus=target_skus,
                    magento_connections=magento_connections,
                    shopify_connections=shopify_connections,
                    dry_run=dry_run,
                    prune_magento_orphans=prune_magento_orphans,
                    push_magento_images=push_magento_images,
                    push_shopify_images=push_shopify_images,
                    batch_size=batch_size,
                )
            if not dry_run and run_local_cleanup:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    return {
        "status": "ok",
        "dry_run": dry_run,
        "run_local_cleanup": run_local_cleanup,
        "run_outbound_cleanup": run_outbound_cleanup,
        "local": local_result,
        "outbound": outbound_result,
    }


@app.get("/api/dashboard/shared-skus")
async def dashboard_shared_skus(
    search: Optional[str] = Query(None),
    master_skus: Optional[str] = Query(None),
    membership_mode: Optional[str] = Query(None),
    limit: int = Query(250, ge=1, le=1000),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.shared_sku_membership import list_shared_sku_memberships

    try:
        with get_session() as session:
            rows = list_shared_sku_memberships(
                session,
                search=search,
                master_skus=master_skus,
                membership_mode=membership_mode,
                limit=limit,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "count": len(rows), "rows": rows}


@app.get("/api/dashboard/shared-skus/{master_sku}")
async def dashboard_shared_sku_detail(master_sku: str):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.shared_sku_membership import get_shared_sku_membership_detail

    try:
        with get_session() as session:
            payload = get_shared_sku_membership_detail(session, master_sku)
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"status": "ok", "item": payload}


@app.get("/api/dashboard/shared-skus-bulk")
async def dashboard_shared_sku_bulk_detail(master_skus: str = Query(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.shared_sku_membership import get_shared_sku_membership_details, split_master_sku_values

    try:
        with get_session() as session:
            items = get_shared_sku_membership_details(session, split_master_sku_values(master_skus))
    except ValueError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"status": "ok", "count": len(items), "items": items}


@app.put("/api/dashboard/shared-skus/{master_sku}")
async def dashboard_shared_sku_upsert(master_sku: str, payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.shared_sku_membership import upsert_shared_sku_membership

    try:
        with get_session() as session:
            item = upsert_shared_sku_membership(
                session,
                master_sku=master_sku,
                membership_mode=payload.get("membership_mode"),
                collection_path_slugs=list(payload.get("collection_path_slugs") or []),
                notes=payload.get("notes"),
                source_label=payload.get("source_label") or "dashboard",
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "item": item}


@app.put("/api/dashboard/shared-skus-bulk")
async def dashboard_shared_sku_bulk_upsert(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.shared_sku_membership import bulk_upsert_shared_sku_memberships

    master_skus = payload.get("master_skus")
    if master_skus is None:
        master_skus = payload.get("master_sku")
    try:
        with get_session() as session:
            result = bulk_upsert_shared_sku_memberships(
                session,
                master_skus=master_skus or [],
                membership_mode=payload.get("membership_mode"),
                collection_path_slugs=list(payload.get("collection_path_slugs") or []),
                notes=payload.get("notes"),
                source_label=payload.get("source_label") or "dashboard",
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/dashboard/shared-skus/upload")
async def dashboard_shared_skus_upload(file: UploadFile = File(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}") from exc
    from db.shared_sku_membership import import_shared_sku_membership_dataframe

    try:
        with get_session() as session:
            result = import_shared_sku_membership_dataframe(session, df)
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.get("/api/dashboard/collections/{path_slug:path}/skus")
async def dashboard_collection_skus(
    path_slug: str,
    search: Optional[str] = Query(None),
    limit: int = Query(500, ge=1, le=2000),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_sku_mapping import discover_collection_skus

    with get_session() as session:
        try:
            payload = discover_collection_skus(session, path_slug=path_slug, search=search, limit=limit)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **payload}


@app.get("/api/dashboard/collections/{path_slug:path}/channel-targets")
async def dashboard_collection_channel_targets(
    path_slug: str,
    channel: Optional[str] = Query(None, description="magento or shopify; omit for both"),
    connection_id: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_pages import get_collection_landing_page
    from db.collection_landing_push import describe_collection_channel_targets

    channel_code = str(channel or "").strip().lower() or None
    if channel_code and channel_code not in {"magento", "shopify"}:
        raise HTTPException(status_code=400, detail="channel must be magento or shopify")
    with get_session() as session:
        row = get_collection_landing_page(session, path_slug)
        if not row:
            raise HTTPException(status_code=404, detail="Collection landing page not found")
        payload = describe_collection_channel_targets(
            session,
            row,
            channel_code=channel_code,
            connection_id=connection_id,
        )
    return {"status": "ok", **payload}


@app.post("/api/dashboard/collections/{path_slug:path}/skus/manual")
async def dashboard_collection_sku_manual(path_slug: str, payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_sku_mapping import remove_collection_sku_override, upsert_collection_sku_override

    action = str(payload.get("action") or "").strip().lower()
    master_sku = str(payload.get("master_sku") or "").strip()
    if not master_sku:
        raise HTTPException(status_code=400, detail="master_sku is required")
    try:
        with get_session() as session:
            if action == "remove":
                removed = remove_collection_sku_override(session, path_slug=path_slug, master_sku=master_sku)
                session.commit()
                return {"status": "ok", "removed": removed, "master_sku": master_sku}
            row = upsert_collection_sku_override(
                session,
                path_slug=path_slug,
                master_sku=master_sku,
                action=action or "include",
                notes=payload.get("notes"),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "override": row}


@app.post("/api/dashboard/collections/{path_slug:path}/skus/reconcile")
async def dashboard_collection_sku_reconcile(
    path_slug: str,
    payload: dict = Body(default_factory=dict),
):
    """Apply authoritative SKU membership, clean cross-collection pollution, optionally sync channels."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    dry_run = bool(payload.get("dry_run", True))
    sync_magento = bool(payload.get("sync_magento", payload.get("sync_channels", False)))
    sync_shopify = bool(payload.get("sync_shopify", payload.get("sync_channels", False)))
    magento_connection_id = payload.get("magento_connection_id") or payload.get("connection_id")
    shopify_connection_id = payload.get("shopify_connection_id") or payload.get("connection_id")

    from db.collection_sku_mapping import reconcile_collection_sku_assignments
    from db.collection_landing_pages import get_collection_landing_page

    if dry_run:
        channel_sync: Dict[str, Any] = {}
        try:
            with get_session() as session:
                row = get_collection_landing_page(session, path_slug)
                if not row and not payload.get("collection"):
                    raise HTTPException(status_code=404, detail="Collection landing page not found")
                result = reconcile_collection_sku_assignments(
                    session,
                    path_slug=path_slug,
                    category_l1=payload.get("category_l1") or (row or {}).get("category_l1"),
                    collection=payload.get("collection") or (row or {}).get("collection"),
                    dry_run=True,
                    cleanup_other_paths=bool(payload.get("cleanup_other_paths", True)),
                )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return {"status": "ok", **result, "channel_sync": channel_sync}

    from app.jobs.channel_jobs import enqueue_collection_reconcile_job, job_to_dict

    try:
        with get_session() as session:
            row = get_collection_landing_page(session, path_slug)
            if not row and not payload.get("collection"):
                raise HTTPException(status_code=404, detail="Collection landing page not found")
            magento_native = (
                _resolve_native_connection_id(session, int(magento_connection_id))
                if magento_connection_id
                else None
            )
            shopify_native = None
            if shopify_connection_id:
                shopify_conn = _resolve_shopify_compat_connection(session, shopify_connection_id)
                shopify_native = shopify_conn["native_id"]
            connection = _primary_collection_job_connection(
                session,
                magento_connection_id=magento_connection_id,
                shopify_connection_id=shopify_connection_id,
            )
            job = enqueue_collection_reconcile_job(
                session,
                channel_connection_id=connection["id"],
                native_connection_id=connection["native_id"],
                channel_code=_channel_code_for_connection(connection),
                channel_type=connection["channel_type"],
                path_slug=path_slug,
                category_l1=payload.get("category_l1") or (row or {}).get("category_l1"),
                collection=payload.get("collection") or (row or {}).get("collection"),
                cleanup_other_paths=bool(payload.get("cleanup_other_paths", True)),
                sync_magento=sync_magento,
                sync_shopify=sync_shopify,
                magento_native_connection_id=magento_native,
                shopify_native_connection_id=shopify_native,
            )
            data = job_to_dict(job)
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    return {
        "status": "queued",
        "job_id": data["id"],
        "poll_url": f"/api/channel-jobs/{data['id']}",
        "job": data,
        "message": "Collection SKU reconcile queued; poll poll_url until terminal.",
    }


@app.post("/api/dashboard/collections/reconcile-all")
async def dashboard_collections_reconcile_all(
    payload: dict = Body(default_factory=dict),
):
    """Reconcile SKU assignments for every taxonomy-backed collection."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_sku_mapping import reconcile_all_collection_assignments

    dry_run = bool(payload.get("dry_run", True))
    if dry_run:
        with get_session() as session:
            result = reconcile_all_collection_assignments(
                session,
                category_l1=payload.get("category_l1"),
                dry_run=True,
            )
        return {"status": "ok", **result}

    from app.jobs.channel_jobs import enqueue_collection_reconcile_all_job, job_to_dict

    with get_session() as session:
        connection = _primary_collection_job_connection(session)
        job = enqueue_collection_reconcile_all_job(
            session,
            channel_connection_id=connection["id"],
            native_connection_id=connection["native_id"],
            channel_code=_channel_code_for_connection(connection),
            channel_type=connection["channel_type"],
            category_l1=payload.get("category_l1"),
        )
        data = job_to_dict(job)
        session.commit()
    return {
        "status": "queued",
        "job_id": data["id"],
        "poll_url": f"/api/channel-jobs/{data['id']}",
        "job": data,
        "message": "Collection reconcile-all queued; poll poll_url until terminal.",
    }


@app.post("/api/dashboard/collections/{path_slug:path}/link-registry")
async def dashboard_collection_link_registry(path_slug: str, payload: dict = Body(default_factory=dict)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_taxonomy_bridge import link_collection_registry_to_landing

    try:
        with get_session() as session:
            result = link_collection_registry_to_landing(
                session,
                path_slug=path_slug,
                registry_id=payload.get("registry_id"),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.get("/api/dashboard/collections/{path_slug:path}")
async def dashboard_collection_detail(path_slug: str):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_pages import get_collection_landing_page

    with get_session() as session:
        row = get_collection_landing_page(session, path_slug)
    if not row:
        raise HTTPException(status_code=404, detail="Collection landing page not found")
    return {"status": "ok", "collection": row}


@app.get("/api/channel/collections/export")
async def channel_collections_export(
    channel: str = Query(..., description="magento or shopify"),
    category_l1: Optional[str] = Query("Kitchen Cabinets"),
    connection_id: Optional[int] = Query(None),
    include_inferred: bool = Query(True),
    include_skus: bool = Query(True),
    limit: int = Query(500, ge=1, le=1000),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_channel_export import export_collections_for_channel

    channel_code = str(channel or "").strip().lower()
    if channel_code not in {"magento", "shopify"}:
        raise HTTPException(status_code=400, detail="channel must be magento or shopify")
    with get_session() as session:
        payload = export_collections_for_channel(
            session,
            channel_code=channel_code,
            category_l1=category_l1,
            connection_id=connection_id,
            include_inferred=include_inferred,
            include_skus=include_skus,
            limit=limit,
        )
    return {"status": "ok", **payload}


@app.post("/api/channel/collections/bootstrap")
async def channel_collections_bootstrap(
    channel: str = Query(..., description="magento or shopify"),
    connection_id: Optional[int] = Query(None),
    dry_run: bool = Query(False),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_push import bootstrap_collection_landing_channel

    channel_code = str(channel or "").strip().lower()
    if channel_code not in {"magento", "shopify"}:
        raise HTTPException(status_code=400, detail="channel must be magento or shopify")
    if dry_run:
        try:
            with get_session() as session:
                result = bootstrap_collection_landing_channel(
                    session,
                    channel_code=channel_code,
                    connection_id=connection_id,
                    dry_run=True,
                )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return {"status": "ok", **result}

    from app.jobs.channel_jobs import enqueue_collection_bootstrap_job, job_to_dict

    try:
        with get_session() as session:
            connection = _resolve_channel_compat_connection(session, channel_code, connection_id)
            job = enqueue_collection_bootstrap_job(
                session,
                channel_connection_id=connection["id"],
                native_connection_id=connection["native_id"],
                channel_code=_channel_code_for_connection(connection),
                channel_type=connection["channel_type"],
            )
            data = job_to_dict(job)
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {
        "status": "queued",
        "job_id": data["id"],
        "poll_url": f"/api/channel-jobs/{data['id']}",
        "job": data,
        "message": "Collection bootstrap queued; poll poll_url until terminal.",
    }


@app.get("/api/channel/collections/magento-attribute-patch")
async def channel_collections_magento_attribute_patch():
    """Download Magento DataPatch PHP to create hs_* category attributes."""
    from fastapi.responses import PlainTextResponse

    from magento.collection_landing_setup import (
        MAGENTO_SETUP_PATCH_CLASS,
        MAGENTO_SETUP_PATCH_RELATIVE_PATH,
        build_magento_category_attributes_data_patch,
    )

    patch = build_magento_category_attributes_data_patch()
    return PlainTextResponse(
        content=patch,
        media_type="text/plain; charset=utf-8",
        headers={
            "Content-Disposition": (
                f'attachment; filename="{MAGENTO_SETUP_PATCH_CLASS}.php"'
            ),
            "X-Deploy-Path": MAGENTO_SETUP_PATCH_RELATIVE_PATH,
        },
    )


@app.get("/api/channel/collections/magento-attribute-module")
async def channel_collections_magento_attribute_module():
    """Return deployable Magento module files for hs_* category attributes."""
    from magento.collection_landing_setup import (
        build_magento_collection_landing_module_files,
        magento_collection_landing_module_deploy_instructions,
    )

    return {
        "status": "ok",
        **magento_collection_landing_module_deploy_instructions(),
        "files": build_magento_collection_landing_module_files(),
    }


@app.post("/api/channel/collections/push")
async def channel_collections_push(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_push import push_collection_landing_channel

    channel_code = str(payload.get("channel") or payload.get("channel_code") or "").strip().lower()
    if channel_code not in {"magento", "shopify"}:
        raise HTTPException(status_code=400, detail="channel must be magento or shopify")
    dry_run = bool(payload.get("dry_run", True))
    provision_missing_remote = bool(payload.get("provision_missing_remote", False))
    bootstrap_first = bool(payload.get("bootstrap_first", True))
    if dry_run:
        try:
            with get_session() as session:
                result = push_collection_landing_channel(
                    session,
                    channel_code=channel_code,
                    connection_id=payload.get("connection_id"),
                    path_slug=payload.get("path_slug"),
                    category_l1=payload.get("category_l1"),
                    only_with_landing_content=bool(payload.get("only_with_landing_content", True)),
                    dry_run=True,
                    provision_missing_remote=provision_missing_remote,
                    apply_listing_paths=bool(payload.get("apply_listing_paths", True)),
                    sync_products=bool(payload.get("sync_products", True)),
                    sync_shopping_icons=bool(payload.get("sync_shopping_icons", True)),
                    limit=int(payload.get("limit") or 200),
                )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return {"status": "ok", **result}

    from app.jobs.channel_jobs import enqueue_collection_push_job, job_to_dict

    try:
        with get_session() as session:
            connection = _resolve_channel_compat_connection(
                session,
                channel_code,
                payload.get("connection_id"),
            )
            job = enqueue_collection_push_job(
                session,
                channel_connection_id=connection["id"],
                native_connection_id=connection["native_id"],
                channel_code=_channel_code_for_connection(connection),
                channel_type=connection["channel_type"],
                path_slug=payload.get("path_slug"),
                category_l1=payload.get("category_l1"),
                only_with_landing_content=bool(payload.get("only_with_landing_content", True)),
                provision_missing_remote=provision_missing_remote,
                apply_listing_paths=bool(payload.get("apply_listing_paths", True)),
                sync_products=bool(payload.get("sync_products", True)),
                sync_shopping_icons=bool(payload.get("sync_shopping_icons", True)),
                limit=int(payload.get("limit") or 200),
                bootstrap_first=bootstrap_first,
            )
            data = job_to_dict(job)
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {
        "status": "queued",
        "job_id": data["id"],
        "poll_url": f"/api/channel-jobs/{data['id']}",
        "job": data,
        "message": "Collection push queued; poll poll_url until terminal.",
    }


@app.post("/api/channel/collections/sync-magento-products")
async def channel_collections_sync_magento_products(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_landing_push import sync_magento_collection_products_channel

    dry_run = bool(payload.get("dry_run", True))
    try:
        with get_session() as session:
            result = sync_magento_collection_products_channel(
                session,
                connection_id=payload.get("connection_id"),
                path_slug=payload.get("path_slug"),
                category_l1=payload.get("category_l1"),
                apply_listing_paths=bool(payload.get("apply_listing_paths", True)),
                dry_run=dry_run,
                limit=int(payload.get("limit") or 200),
            )
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.get("/api/channel-connections")
async def compat_list_channel_connections():
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        return {"connections": _all_compat_connections(session)}


@app.post("/api/channel-connections")
async def compat_create_channel_connection(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from sqlalchemy import select
    from db.models import ChannelConnection
    from db.schemas import MagentoConnectionUpsert
    from shopify.connections import upsert_connection as upsert_shopify_connection

    channel_type = str(payload.get("channel_type") or "").strip().lower()
    if not channel_type:
        raise HTTPException(status_code=400, detail="channel_type is required")
    config = payload.get("config") or {}
    with get_session() as session:
        if channel_type == "magento":
            repo = SqlAlchemyMagentoConnectionRepository(session)
            conn_id = repo.upsert_connection(
                MagentoConnectionUpsert(
                    store_base_url=payload.get("base_url") or config.get("store_base_url") or "",
                    magento_api_base_url=payload.get("api_base_url") or config.get("magento_api_base_url"),
                    internal_api_base_url=config.get("internal_api_base_url"),
                    internal_host_header=config.get("internal_host_header"),
                    prefer_internal_api=bool(config.get("prefer_internal_api")),
                    rest_base_path=config.get("rest_base_path") or "V1",
                    store_code=payload.get("store_code") or config.get("store_code"),
                    environment=payload.get("environment") or "production",
                    consumer_key=config.get("consumer_key") or "",
                    consumer_secret=config.get("consumer_secret") or "",
                    access_token=config.get("access_token") or "",
                    access_token_secret=config.get("access_token_secret") or "",
                    status=payload.get("status") or "active",
                    signature_method=config.get("signature_method") or "HMAC-SHA256",
                )
            )
            session.commit()
            return {"id": _compat_id("magento", conn_id), "status": "created"}
        if channel_type == "shopify":
            row = upsert_shopify_connection(
                session,
                {
                    "shop_code": payload.get("channel_code"),
                    "shop_domain": config.get("SHOPIFY_SHOP_DOMAIN") or payload.get("store_code"),
                    "client_id": config.get("SHOPIFY_CLIENT_ID"),
                    "client_secret": config.get("SHOPIFY_CLIENT_SECRET"),
                    "api_version": config.get("SHOPIFY_API_VERSION") or "2026-04",
                    "status": payload.get("status") or "active",
                    "environment": payload.get("environment") or "production",
                },
            )
            session.commit()
            return {"id": _compat_id("shopify", row.id), "status": "created"}

        row = session.scalar(
            select(ChannelConnection).where(
                ChannelConnection.channel_type == channel_type,
                ChannelConnection.channel_code == str(payload.get("channel_code") or channel_type).strip(),
                ChannelConnection.environment == str(payload.get("environment") or "production").strip(),
            )
        )
        if row is None:
            row = ChannelConnection(
                channel_type=channel_type,
                channel_code=str(payload.get("channel_code") or channel_type).strip(),
                environment=str(payload.get("environment") or "production").strip(),
            )
            session.add(row)
        row.display_name = payload.get("display_name")
        row.base_url = payload.get("base_url")
        row.api_base_url = payload.get("api_base_url")
        row.store_code = payload.get("store_code")
        row.status = payload.get("status") or "active"
        row.config = config
        row.capabilities = payload.get("capabilities") or {}
        session.commit()
        return {"id": _compat_id("generic", row.id), "status": "created"}


@app.get("/api/channel-connections/{connection_id}/overview")
async def compat_channel_connection_overview(connection_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from sqlalchemy import func, select
    from db.models import ChannelAttributeAlias, ChannelJob, ChannelPublishState, ChannelTransformRule, MagentoCatalogState, MagentoSyncJob

    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        pipeline_code = _pipeline_channel_code(connection)
        mappings = session.scalar(
            select(func.count()).select_from(ChannelAttributeAlias).where(ChannelAttributeAlias.channel_code == pipeline_code)
        ) or 0
        rules = session.scalar(
            select(func.count()).select_from(ChannelTransformRule).where(ChannelTransformRule.channel_code == pipeline_code)
        ) or 0
        published = session.scalar(
            select(func.count()).select_from(ChannelPublishState).where(ChannelPublishState.channel_code == pipeline_code)
        ) or 0
        from app.jobs.channel_jobs import refresh_delegated_job

        recent_jobs = [
            _channel_job_dict(refresh_delegated_job(session, job))
            for job in session.scalars(
                select(ChannelJob)
                .where(ChannelJob.channel_connection_id == connection_id)
                .order_by(ChannelJob.requested_at.desc())
                .limit(20)
            ).all()
        ]
        remote_snapshot = 0
        source_snapshot = 0
        last_pull_at = None
        last_push_at = None
        sync_summary = {"master_total": 0, "in_channel": published, "never_pushed": 0, "has_errors": 0, "in_sync_approx": 0}
        channel_type = connection["channel_type"]
        native_id = connection["native_id"]
        from db.source_snapshot_export import count_current_source_snapshots

        source_snapshot = count_current_source_snapshots(
            session,
            channel_type,
            connection_id=native_id if channel_type in {"magento", "shopify"} else None,
        )
        for job in recent_jobs:
            if job.get("job_type") == "pull" and job.get("finished_at"):
                last_pull_at = job.get("finished_at")
                break
        for job in recent_jobs:
            if job.get("job_type") == "push" and job.get("finished_at"):
                last_push_at = job.get("finished_at")
                break

        if channel_type == "magento":
            remote_snapshot = session.scalar(
                select(func.count()).select_from(MagentoCatalogState).where(MagentoCatalogState.connection_id == native_id)
            ) or 0
            sync_summary = (await magento_sync_status(native_id))["summary"]
        elif channel_type == "shopify":
            sync_summary = {
                "shopify_snapshot_skus": source_snapshot,
                "published": published,
            }
        elif channel_type == "plytix":
            sync_summary = {
                "plytix_snapshot_skus": source_snapshot,
                "published": published,
            }
        return {
            "connection": connection,
            "counts": {
                "channel_products": published,
                "transformed": 0,
                "published": published,
                "remote_snapshot": remote_snapshot,
                "source_snapshot": source_snapshot,
                "attribute_mappings": mappings,
                "transform_rules": rules,
            },
            "last_pull_at": last_pull_at,
            "last_push_at": last_push_at,
            "sync_summary": sync_summary,
            "recent_jobs": recent_jobs,
        }


@app.patch("/api/channel-connections/{connection_id}")
async def compat_update_channel_connection(connection_id: int, payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    channel_type, native_id = _decode_compat_id(connection_id)
    if channel_type != "magento":
        raise HTTPException(status_code=400, detail="Only Magento connection editing is supported here")

    from db.models import MagentoConnection

    editable = {
        "store_base_url",
        "magento_api_base_url",
        "internal_api_base_url",
        "internal_host_header",
        "prefer_internal_api",
        "rest_base_path",
        "store_code",
        "environment",
        "status",
        "consumer_key",
        "consumer_secret",
        "access_token",
        "access_token_secret",
        "signature_method",
    }
    updates = {k: v for k, v in payload.items() if k in editable}
    if not updates:
        raise HTTPException(status_code=400, detail="No valid fields to update")

    with get_session() as session:
        row = session.get(MagentoConnection, native_id)
        if row is None:
            raise HTTPException(status_code=404, detail="Connection not found")

        for key, value in updates.items():
            if key in {"store_base_url", "magento_api_base_url", "internal_api_base_url"} and value:
                value = str(value).strip().rstrip("/")
            elif key == "internal_host_header" and value is not None:
                value = str(value).strip() or None
            elif key == "prefer_internal_api":
                value = bool(value)
            elif isinstance(value, str):
                value = value.strip()
            setattr(row, key, value)

        session.commit()
    return {"updated": sorted(updates.keys())}


@app.post("/api/channel-connections/{connection_id}/test")
async def compat_test_channel_connection(connection_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    channel_type, native_id = _decode_compat_id(connection_id)
    if channel_type == "magento":
        return await magento_verify_connection(native_id)
    if channel_type == "shopify":
        return await shopify_verify_connection(native_id)
    from db.models import ChannelConnection
    with get_session() as session:
        row = session.get(ChannelConnection, native_id)
        if not row:
            raise HTTPException(status_code=404, detail="Connection not found")
        row.last_verified_at = datetime.utcnow()
        row.last_error = None
        session.commit()
    return {"verified": True, "message": "Stored generic channel connection; no remote verifier is configured."}


@app.post("/api/channel-connections/{connection_id}/attributes/refresh")
async def compat_channel_attributes_refresh(connection_id: int):
    """Queue latest attribute/field registry refresh for a Magento or Shopify channel connection."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.channel_jobs import enqueue_channel_job, job_to_dict

    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        if connection["channel_type"] not in {"magento", "shopify"}:
            raise HTTPException(
                status_code=400,
                detail=f"Attribute refresh is not supported for channel type '{connection['channel_type']}'",
            )
        job = enqueue_channel_job(
            session,
            channel_connection_id=connection_id,
            channel_type=connection["channel_type"],
            native_connection_id=connection["native_id"],
            channel_code=_channel_code_for_connection(connection),
            job_type="attribute_pull",
            dry_run=False,
            mode="full",
            notes="attribute_pull",
        )
        data = job_to_dict(job)
        session.commit()
    return {"status": "queued", "job_id": data["id"], "job": data}


@app.post("/api/channel-connections/{connection_id}/jobs")
async def compat_create_channel_job(connection_id: int, payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.channel_jobs import enqueue_channel_job, job_to_dict

    job_type = str(payload.get("job_type") or "").strip().lower()
    if job_type not in {"pull", "push", "push_images", "media_cleanup", "remove_products", "transform", "test", "attribute_pull", "sku_link", "collection_bootstrap", "collection_push", "collection_reconcile", "collection_reconcile_all"}:
        raise HTTPException(status_code=400, detail="job_type must be pull, push, push_images, media_cleanup, remove_products, transform, test, attribute_pull, sku_link, collection_bootstrap, collection_push, collection_reconcile, or collection_reconcile_all")
    # Pull defaults to live (persist snapshots); push defaults to dry-run for safety.
    default_dry_run = job_type != "pull"
    default_mode = "full" if job_type == "pull" else "incremental"
    auto_link_skus = payload.get("auto_link_skus")
    if auto_link_skus is None:
        auto_link_skus = job_type == "pull"
    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        job = enqueue_channel_job(
            session,
            channel_connection_id=connection_id,
            channel_type=connection["channel_type"],
            native_connection_id=connection["native_id"],
            channel_code=_channel_code_for_connection(connection),
            job_type=job_type,
            dry_run=bool(payload.get("dry_run", default_dry_run)),
            mode=str(payload.get("mode") or default_mode).lower(),
            options={
                "auto_link_skus": bool(auto_link_skus),
                "only_assigned": bool(payload.get("only_assigned", True)),
            },
        )
        data = job_to_dict(job)
        session.commit()
    return {"status": "queued", "job_id": data["id"], "job": data}


@app.get("/api/channel-jobs/{job_id}")
async def compat_get_channel_job(job_id: int):
    from app.jobs.channel_jobs import refresh_delegated_job
    from db.models import ChannelJob
    with get_session() as session:
        job = session.get(ChannelJob, job_id)
        if not job:
            raise HTTPException(status_code=404, detail="Job not found")
        # Delegated Magento pushes finish in magento_worker; fold that outcome
        # back onto the channel_job so polling clients see the real status.
        job = refresh_delegated_job(session, job)
        data = _channel_job_dict(job)
        session.commit()
        return data


@app.get("/api/channel-connections/{connection_id}/attribute-mappings")
async def compat_list_attribute_mappings(connection_id: int):
    from sqlalchemy import select
    from db.models import ChannelAttributeAlias

    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        pipeline_code = _pipeline_channel_code(connection)
        rows = session.scalars(
            select(ChannelAttributeAlias)
            .where(ChannelAttributeAlias.channel_code == pipeline_code)
            .order_by(ChannelAttributeAlias.canonical_code, ChannelAttributeAlias.channel_attribute_code)
        ).all()
        mappings = [
            {
                "id": row.id,
                "canonical_code": row.canonical_code,
                "channel_attribute_code": row.channel_attribute_code,
                "direction": "outbound",
                "is_active": row.is_active,
                "action": row.action,
                "data_type": row.data_type,
                "transform_rule": row.transform_rule or {},
            }
            for row in rows
        ]
    return {"mappings": mappings}


@app.post("/api/channel-connections/{connection_id}/attribute-mappings")
async def compat_add_attribute_mapping(connection_id: int, payload: dict = Body(...)):
    from sqlalchemy import select
    from db.models import ChannelAttributeAlias

    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        pipeline_code = _pipeline_channel_code(connection)
        canonical_code = str(payload.get("canonical_code") or "").strip().lower()
        channel_attribute_code = str(payload.get("channel_attribute_code") or "").strip()
        if not canonical_code or not channel_attribute_code:
            raise HTTPException(status_code=400, detail="canonical_code and channel_attribute_code are required")
        row = session.scalar(
            select(ChannelAttributeAlias).where(
                ChannelAttributeAlias.channel_code == pipeline_code,
                ChannelAttributeAlias.canonical_code == canonical_code,
                ChannelAttributeAlias.channel_attribute_code == channel_attribute_code,
            )
        )
        if row is None:
            row = ChannelAttributeAlias(
                channel_code=pipeline_code,
                canonical_code=canonical_code,
                channel_attribute_code=channel_attribute_code,
            )
            session.add(row)
        row.is_active = True
        row.action = payload.get("action") or "use_existing"
        session.commit()
        return {"id": row.id, "status": "ok"}


@app.delete("/api/channel-connections/{connection_id}/attribute-mappings/{mapping_id}")
async def compat_delete_attribute_mapping(connection_id: int, mapping_id: int):
    from db.models import ChannelAttributeAlias
    with get_session() as session:
        row = session.get(ChannelAttributeAlias, mapping_id)
        if not row:
            raise HTTPException(status_code=404, detail="Mapping not found")
        row.is_active = False
        session.commit()
    return {"status": "ok", "id": mapping_id, "deleted": True}


@app.get("/api/channel-connections/{connection_id}/transform-rules")
async def compat_list_transform_rules(connection_id: int):
    from sqlalchemy import select
    from db.models import ChannelTransformRule
    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        pipeline_code = _pipeline_channel_code(connection)
        rows = session.scalars(
            select(ChannelTransformRule)
            .where(ChannelTransformRule.channel_code == pipeline_code)
            .order_by(ChannelTransformRule.priority, ChannelTransformRule.target)
        ).all()
    return {
        "rules": [
            {
                "id": row.id,
                "target": row.target,
                "rule_name": row.rule_name,
                "priority": row.priority,
                "rule": row.rule or {},
                "is_active": row.is_active,
            }
            for row in rows
        ]
    }


@app.post("/api/channel-connections/{connection_id}/transform-rules")
async def compat_add_transform_rule(connection_id: int, payload: dict = Body(...)):
    from sqlalchemy import select
    from db.models import ChannelTransformRule
    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        pipeline_code = _pipeline_channel_code(connection)
        target = str(payload.get("target") or "").strip().lower()
        rule_name = str(payload.get("rule_name") or "").strip().lower()
        if not target or not rule_name:
            raise HTTPException(status_code=400, detail="target and rule_name are required")
        row = session.scalar(
            select(ChannelTransformRule).where(
                ChannelTransformRule.channel_code == pipeline_code,
                ChannelTransformRule.target == target,
                ChannelTransformRule.rule_name == rule_name,
            )
        )
        if row is None:
            row = ChannelTransformRule(channel_code=pipeline_code, target=target, rule_name=rule_name)
            session.add(row)
        row.priority = int(payload.get("priority") or 100)
        row.rule = payload.get("rule") or {}
        row.is_active = bool(payload.get("is_active", True))
        session.commit()
        return {"id": row.id, "status": "ok"}


@app.delete("/api/channel-connections/{connection_id}/transform-rules/{rule_id}")
async def compat_delete_transform_rule(connection_id: int, rule_id: int):
    from db.models import ChannelTransformRule
    with get_session() as session:
        row = session.get(ChannelTransformRule, rule_id)
        if not row:
            raise HTTPException(status_code=404, detail="Rule not found")
        row.is_active = False
        session.commit()
    return {"status": "ok", "id": rule_id, "deleted": True}


@app.post("/api/channel-transformations/{connection_id}/run")
async def compat_run_channel_transformation(connection_id: int, limit: Optional[int] = Query(None)):
    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        pipeline_code = _pipeline_channel_code(connection)
        try:
            payloads = build_channel_product_payloads(
                session,
                pipeline_code,
                limit=limit,
                only_assigned=False,
                connection_id=connection_id,
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))
    return {
        "status": "ok",
        "connection_id": connection_id,
        "channel_code": pipeline_code,
        "connection_code": _channel_code_for_connection(connection),
        "count": len(payloads),
    }


@app.get("/api/channel-transformations/{connection_id}/normalized.csv")
async def compat_channel_transformation_csv(connection_id: int, limit: Optional[int] = Query(None)):
    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        pipeline_code = _pipeline_channel_code(connection)
        try:
            payloads = build_channel_product_payloads(
                session,
                pipeline_code,
                limit=limit,
                only_assigned=False,
                connection_id=connection_id,
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))
    rows = []
    for payload in payloads:
        row = {"sku": payload["sku"], "channel_code": pipeline_code, "price": payload.get("price")}
        row.update(payload.get("fields") or {})
        rows.append(row)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": f"attachment; filename={pipeline_code}_normalized.csv"},
    )


@app.post("/api/seo/import-csv")
async def compat_import_seo_csv(
    file: UploadFile = File(...),
    scope: str = Query("canonical"),
    locale: Optional[str] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    with get_session() as session:
        stats = import_master_override_dataframe(
            session,
            df,
            source_label="product_seo",
            default_channel_code="" if scope == "canonical" else scope,
        )
        session.commit()
    return {"status": "ok", "source": "product_seo", "scope": scope, "locale": locale, **stats}


@app.post("/api/master-products/import-csv")
async def compat_import_master_products_csv(
    file: UploadFile = File(...),
    mode: str = Query("partial"),
    source_label: str = Query("admin_master_csv"),
):
    return await import_master_skus_csv(file=file, source_label=source_label)


DASHBOARD_INGEST_DIR = os.path.join(BASE_DIR, "data", "dashboard_ingest")


def _dashboard_ingest_paths(run_id: str) -> tuple[str, str]:
    safe_id = re.sub(r"[^0-9A-Za-z_.-]", "", run_id)
    run_dir = os.path.abspath(os.path.join(DASHBOARD_INGEST_DIR, safe_id))
    if not run_dir.startswith(os.path.abspath(DASHBOARD_INGEST_DIR)):
        raise HTTPException(status_code=400, detail="Invalid ingest run id")
    return run_dir, os.path.join(run_dir, "metadata.json")


def _read_dashboard_ingest(run_id: str) -> dict:
    _, meta_path = _dashboard_ingest_paths(run_id)
    if not os.path.exists(meta_path):
        raise HTTPException(status_code=404, detail="Ingest run not found")
    with open(meta_path, "r", encoding="utf-8") as handle:
        return json.load(handle)


def _write_dashboard_ingest(run_id: str, metadata: dict) -> None:
    run_dir, meta_path = _dashboard_ingest_paths(run_id)
    os.makedirs(run_dir, exist_ok=True)
    with open(meta_path, "w", encoding="utf-8") as handle:
        json.dump(metadata, handle, indent=2, sort_keys=True, default=str)


def _load_dashboard_ingest(run_id: str) -> dict:
    _, meta_path = _dashboard_ingest_paths(run_id)
    if not os.path.exists(meta_path):
        raise FileNotFoundError(f"Ingest run not found: {run_id}")
    with open(meta_path, "r", encoding="utf-8") as handle:
        return json.load(handle)


def _enqueue_catalog_provision_after_master_upload(metadata: dict) -> Optional[str]:
    """Queue catalog provision after master ingest; remote writes are controlled by env flags."""
    if metadata.get("import_type") != "master":
        return None
    from app.jobs.channel_catalog_provision_enqueue import start_catalog_provision_job
    from settings import load_channel_catalog_provision_trigger_config

    cfg = load_channel_catalog_provision_trigger_config()
    if not cfg.enabled_on_master_upload:
        return None
    params = {
        "magento_connection_id": None,
        "shopify_connection_id": None,
        "only_assigned": cfg.only_assigned,
        "dry_run": cfg.dry_run,
        "include_magento": cfg.include_magento,
        "include_shopify": cfg.include_shopify,
        "create_missing": cfg.create_missing,
        "assign_skus": cfg.assign_skus,
        "delta_only": cfg.delta_only,
        "seed_master_listing_paths": cfg.seed_master_listing_paths,
        "provision_attributes": cfg.provision_attributes,
        "include_brand_collections": cfg.include_brand_collections,
        "shopify_brand_filter": cfg.shopify_brand_filter,
        "limit": None,
        "trigger": "master_upload",
        "ingest_run_id": metadata.get("id"),
    }
    return start_catalog_provision_job(params)


def _process_dashboard_ingest_run(run_id: str) -> None:
    """Background worker: parse uploaded CSV and persist import stats to run metadata."""
    logger = logging.getLogger(__name__)
    try:
        metadata = _load_dashboard_ingest(run_id)
    except FileNotFoundError as exc:
        logger.error("dashboard_ingest: %s", exc)
        return

    upload_path = metadata.get("upload_path")
    if not upload_path or not os.path.exists(upload_path):
        metadata["status"] = "failed"
        metadata["notes"] = "Upload file is missing"
        metadata["finished_at"] = datetime.utcnow().isoformat()
        _write_dashboard_ingest(run_id, metadata)
        return

    try:
        def _record_progress(progress: dict) -> None:
            latest = _load_dashboard_ingest(run_id)
            latest["status"] = "processing"
            latest["progress"] = {
                **(latest.get("progress") or {}),
                **progress,
                "updated_at": datetime.utcnow().isoformat(),
            }
            latest["metadata"] = {
                **(latest.get("metadata") or {}),
                "progress_stage": progress.get("stage"),
                "progress_message": progress.get("message") or progress.get("stage"),
            }
            _write_dashboard_ingest(run_id, latest)

        metadata["progress"] = {
            "stage": "read_csv",
            "message": "Reading uploaded CSV",
            "updated_at": datetime.utcnow().isoformat(),
        }
        _write_dashboard_ingest(run_id, metadata)
        df = pd.read_csv(upload_path, dtype=str, keep_default_na=False)
        _record_progress({"stage": "csv_loaded", "rows": len(df), "columns": len(df.columns)})
        with get_session() as session:
            if metadata["import_type"] == "seo":
                stats = import_master_override_dataframe(
                    session,
                    df,
                    source_label="product_seo",
                    default_channel_code="" if metadata.get("scope") == "canonical" else metadata.get("scope", ""),
                )
            elif metadata["import_type"] == "images":
                from db.master_product_images import import_master_product_images_dataframe

                stats = import_master_product_images_dataframe(
                    session,
                    df,
                    source_label=metadata.get("source_label") or "images_csv",
                )
            else:
                # Locked by default: import reconciles SKUs onto existing taxonomy only.
                # Unlock (allow_taxonomy_invent) is stored for follow-up confirm/proposals;
                # invent is never performed by the import itself.
                allow_invent = bool(
                    metadata.get("allow_taxonomy_invent")
                    or (metadata.get("metadata") or {}).get("allow_taxonomy_invent")
                )
                stats = import_master_sku_dataframe(
                    session,
                    df,
                    source_label=metadata.get("source_label") or "admin_master_csv",
                    progress_callback=_record_progress,
                    reconcile_taxonomy=True,
                    resolve_registries=False,
                )
                stats["allow_taxonomy_invent"] = allow_invent
                stats["taxonomy_invent_note"] = (
                    "Invent unlocked for follow-up confirm/proposals"
                    if allow_invent
                    else "Invent locked; linking to existing taxonomy only"
                )
            _record_progress({"stage": "commit", "message": "Committing import transaction"})
            session.commit()
        if metadata["import_type"] == "master":
            try:
                job_id = _enqueue_catalog_provision_after_master_upload(metadata)
                if job_id:
                    metadata["metadata"] = {
                        **(metadata.get("metadata") or {}),
                        "catalog_provision_job_id": job_id,
                        "catalog_provision_poll_url": f"/api/channel-catalogs/provision/{job_id}",
                    }
                    metadata["progress"] = {
                        **(metadata.get("progress") or {}),
                        "catalog_provision_job_id": job_id,
                        "catalog_provision_message": "Catalog provision job queued after master import",
                    }
            except Exception as exc:
                logger.exception("dashboard_ingest catalog provision enqueue failed run_id=%s: %s", run_id, exc)
                metadata["metadata"] = {
                    **(metadata.get("metadata") or {}),
                    "catalog_provision_enqueue_error": str(exc),
                }
        metadata["status"] = "finished"
        metadata["metadata"] = {**(metadata.get("metadata") or {}), **stats}
        metadata["progress"] = {
            **(metadata.get("progress") or {}),
            "stage": "finished",
            "message": "Import finished",
            "updated_at": datetime.utcnow().isoformat(),
        }
        metadata["notes"] = ""
        metadata["finished_at"] = datetime.utcnow().isoformat()
    except Exception as exc:
        logger.exception("dashboard_ingest failed run_id=%s: %s", run_id, exc)
        metadata["status"] = "failed"
        metadata["notes"] = str(exc)
        metadata["finished_at"] = datetime.utcnow().isoformat()
    _write_dashboard_ingest(run_id, metadata)


@app.post("/api/ingest/runs")
async def compat_create_ingest_run(payload: dict = Body(...)):
    import_type = str(payload.get("import_type") or "").strip().lower()
    if import_type not in {"master", "seo", "images"}:
        raise HTTPException(status_code=400, detail="import_type must be master, seo, or images")
    run_id = datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
    metadata = {
        "id": run_id,
        "import_type": import_type,
        "file_name": payload.get("file_name") or "upload.csv",
        "source_label": payload.get("source_label") or (
            "product_seo" if import_type == "seo"
            else "images_csv" if import_type == "images"
            else "admin_master_csv"
        ),
        "mode": payload.get("mode") or "partial",
        "scope": payload.get("scope") or "canonical",
        "locale": payload.get("locale"),
        "allow_taxonomy_invent": bool(payload.get("allow_taxonomy_invent", False)),
        "status": "created",
        "metadata": {
            "allow_taxonomy_invent": bool(payload.get("allow_taxonomy_invent", False)),
        },
        "notes": "",
        "created_at": datetime.utcnow().isoformat(),
    }
    _write_dashboard_ingest(run_id, metadata)
    return {"ingest_run_id": run_id, "status": "created"}


@app.put("/api/ingest/runs/{run_id}/upload")
async def compat_upload_ingest_run(run_id: str, request: Request):
    metadata = _read_dashboard_ingest(run_id)
    run_dir, _ = _dashboard_ingest_paths(run_id)
    upload_path = os.path.join(run_dir, "upload.csv")
    body = await request.body()
    with open(upload_path, "wb") as handle:
        handle.write(body)
    metadata["status"] = "uploaded"
    metadata["upload_path"] = upload_path
    metadata["metadata"] = {**(metadata.get("metadata") or {}), "bytes": len(body)}
    _write_dashboard_ingest(run_id, metadata)
    return {"status": "uploaded", "bytes": len(body)}


@app.post("/api/ingest/runs/{run_id}/process")
async def compat_process_ingest_run(run_id: str):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    metadata = _read_dashboard_ingest(run_id)
    current_status = metadata.get("status")
    if current_status == "finished":
        return {
            "status": "finished",
            "ingest_run_id": run_id,
            "metadata": metadata.get("metadata") or {},
        }
    if current_status == "processing":
        return {"status": "processing", "ingest_run_id": run_id}
    if current_status != "uploaded":
        raise HTTPException(
            status_code=400,
            detail=f"Run not ready for processing (status={current_status})",
        )
    upload_path = metadata.get("upload_path")
    if not upload_path or not os.path.exists(upload_path):
        raise HTTPException(status_code=400, detail="Upload file is missing")

    metadata["status"] = "processing"
    metadata["processing_started_at"] = datetime.utcnow().isoformat()
    _write_dashboard_ingest(run_id, metadata)
    thread = threading.Thread(
        target=_process_dashboard_ingest_run,
        args=(run_id,),
        name=f"dashboard-ingest-{run_id}",
        daemon=True,
    )
    thread.start()
    return {"status": "processing", "ingest_run_id": run_id}


@app.get("/api/ingest/runs/{run_id}")
async def compat_get_ingest_run(run_id: str):
    metadata = _read_dashboard_ingest(run_id)
    return {
        "ingest_run": {
            "id": metadata["id"],
            "status": metadata["status"],
            "import_type": metadata.get("import_type"),
            "metadata": metadata.get("metadata") or {},
            "progress": metadata.get("progress") or {},
            "notes": metadata.get("notes") or "",
            "processing_started_at": metadata.get("processing_started_at"),
            "finished_at": metadata.get("finished_at"),
        }
    }


@app.get("/api/schedules/queue-health")
async def compat_schedule_queue_health():
    from sqlalchemy import func, select
    from db.models import ChannelJob, ChannelSchedule
    with get_session() as session:
        job_counts = {
            str(status_value or "unknown"): int(count)
            for status_value, count in session.execute(
                select(ChannelJob.status, func.count()).group_by(ChannelJob.status)
            ).all()
        }
        enabled_schedules = session.scalar(
            select(func.count()).select_from(ChannelSchedule).where(ChannelSchedule.is_enabled.is_(True))
        ) or 0
        in_flight = [
            _channel_job_dict(job)
            for job in session.scalars(
                select(ChannelJob)
                .where(ChannelJob.status.in_(["started", "running", "queued"]))
                .order_by(ChannelJob.requested_at.desc())
                .limit(20)
            ).all()
        ]
        recent_failures = [
            _channel_job_dict(job)
            for job in session.scalars(
                select(ChannelJob)
                .where(ChannelJob.status == "failed")
                .order_by(ChannelJob.requested_at.desc())
                .limit(20)
            ).all()
        ]
    return {
        "job_counts": job_counts,
        "enabled_schedules": enabled_schedules,
        "in_flight": in_flight,
        "recent_failures": recent_failures,
    }


@app.get("/api/schedules")
async def compat_list_schedules():
    from sqlalchemy import select
    from db.models import ChannelSchedule
    with get_session() as session:
        rows = session.scalars(select(ChannelSchedule).order_by(ChannelSchedule.id)).all()
        schedules = []
        for row in rows:
            schedules.append(
                {
                    "id": row.id,
                    "channel_connection_id": row.channel_connection_id,
                    "connection": _connection_row(session, row.channel_connection_id),
                    "task_type": row.task_type,
                    "cron_expression": row.cron_expression,
                    "mode": row.mode,
                    "dry_run": row.dry_run,
                    "is_enabled": row.is_enabled,
                    "next_run_at": _dt(row.next_run_at),
                    "last_run_at": _dt(row.last_run_at),
                    "last_status": row.last_status,
                    "last_error": row.last_error,
                    "last_enqueued_job_id": row.last_enqueued_job_id,
                }
            )
    return {"schedules": schedules}


@app.post("/api/schedules")
async def compat_create_schedule(payload: dict = Body(...)):
    from app.jobs.channel_scheduler import next_run_after
    from db.models import ChannelSchedule
    connection_id = int(payload.get("channel_connection_id") or 0)
    with get_session() as session:
        connection = _connection_row(session, connection_id)
        if not connection:
            raise HTTPException(status_code=404, detail="Connection not found")
        row = ChannelSchedule(
            channel_connection_id=connection_id,
            channel_type=connection["channel_type"],
            native_connection_id=connection["native_id"],
            channel_code=_channel_code_for_connection(connection),
            task_type=str(payload.get("task_type") or "push").strip().lower(),
            cron_expression=str(payload.get("cron_expression") or "").strip(),
            mode=str(payload.get("mode") or "incremental").strip().lower(),
            dry_run=bool(payload.get("dry_run", True)),
            is_enabled=bool(payload.get("is_enabled", True)),
        )
        if not row.cron_expression:
            raise HTTPException(status_code=400, detail="cron_expression is required")
        try:
            row.next_run_at = next_run_after(row.cron_expression)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))
        session.add(row)
        session.commit()
        return {"id": row.id, "status": "ok", "next_run_at": _dt(row.next_run_at)}


@app.patch("/api/schedules/{schedule_id}")
async def compat_update_schedule(schedule_id: int, payload: dict = Body(...)):
    from app.jobs.channel_scheduler import next_run_after
    from db.models import ChannelSchedule
    with get_session() as session:
        row = session.get(ChannelSchedule, schedule_id)
        if not row:
            raise HTTPException(status_code=404, detail="Schedule not found")
        for field in ("cron_expression", "mode", "task_type"):
            if field in payload and payload[field] is not None:
                setattr(row, field, str(payload[field]).strip())
        for field in ("dry_run", "is_enabled"):
            if field in payload and payload[field] is not None:
                setattr(row, field, bool(payload[field]))
        if "cron_expression" in payload or "is_enabled" in payload:
            row.next_run_at = next_run_after(row.cron_expression) if row.is_enabled else None
        session.commit()
    return {"status": "ok", "id": schedule_id, "next_run_at": _dt(row.next_run_at)}


@app.delete("/api/schedules/{schedule_id}")
async def compat_delete_schedule(schedule_id: int):
    from db.models import ChannelSchedule
    with get_session() as session:
        row = session.get(ChannelSchedule, schedule_id)
        if not row:
            raise HTTPException(status_code=404, detail="Schedule not found")
        session.delete(row)
        session.commit()
    return {"status": "ok", "id": schedule_id, "deleted": True}


@app.post("/api/schedules/{schedule_id}/run-now")
async def compat_run_schedule_now(schedule_id: int):
    from app.jobs.channel_jobs import enqueue_channel_job, job_to_dict
    from db.models import ChannelSchedule
    with get_session() as session:
        row = session.get(ChannelSchedule, schedule_id)
        if not row:
            raise HTTPException(status_code=404, detail="Schedule not found")
        job = enqueue_channel_job(
            session,
            schedule_id=row.id,
            channel_connection_id=row.channel_connection_id,
            channel_type=row.channel_type,
            native_connection_id=row.native_connection_id,
            channel_code=row.channel_code,
            job_type=row.task_type,
            dry_run=row.dry_run,
            mode=row.mode,
        )
        row.last_status = "queued"
        row.last_error = None
        row.last_enqueued_job_id = job.id
        data = job_to_dict(job)
        session.commit()
    return {"status": "queued", "job_id": data["id"], "job": data}


@app.get("/api/channel-exports/{channel_code}/attributes.csv")
async def channel_attribute_preview_csv(
    channel_code: str,
    limit: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
    connection_id: Optional[int] = Query(None),
):
    """Download a dry-run canonical-to-channel attribute export for Magento/Shopify/Plytix."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    try:
        with get_session() as session:
            rows = build_channel_attribute_preview(
                session,
                channel_code,
                limit=limit,
                only_assigned=only_assigned,
                connection_id=connection_id,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": f"attachment; filename={channel_code}_attribute_preview.csv"},
    )


@app.post("/api/channel-pipelines/{channel_code}/push")
async def channel_push(
    channel_code: str,
    dry_run: bool = Query(True),
    limit: Optional[int] = Query(None),
    skus: Optional[List[str]] = Body(None, embed=True),
    shop_code: Optional[str] = Body(None, embed=True),
    connection_id: Optional[int] = Body(None, embed=True),
    force: bool = Query(False),
):
    """Push assigned master catalog SKUs to a channel (async via queue/job workers)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    channel = (channel_code or "").strip().lower()
    if channel not in CHANNEL_CODES:
        raise HTTPException(status_code=400, detail=f"Unsupported channel_code: {channel_code}")
    if channel == "plytix":
        raise HTTPException(status_code=501, detail="Plytix write-back is not implemented yet")
    if channel == "magento":
        if not connection_id:
            raise HTTPException(status_code=400, detail="connection_id is required for Magento push")
        from datetime import datetime as _dt

        from db.magento_repositories import SqlAlchemyMagentoSyncQueueRepository

        label = f"channel-pipeline-magento-{_dt.utcnow().strftime('%Y%m%d-%H%M%S')}"
        options: Dict[str, Any] = {
            "dry_run": dry_run,
            "force_full_sync": force,
            "use_master_catalog": True,
        }
        if limit:
            options["limit_skus"] = skus[:limit] if skus else None
        elif skus:
            options["limit_skus"] = skus
        with get_session() as session:
            queue_id = SqlAlchemyMagentoSyncQueueRepository(session).enqueue(
                connection_id,
                label,
                options=options,
                supersede_queued=True,
            )
            session.commit()
        return {
            "channel_code": channel,
            "status": "queued",
            "queue_id": queue_id,
            "label": label,
            "use_master_catalog": True,
        }

    from shopify.product_sync import push_products

    if dry_run:
        with get_session() as session:
            summary = push_products(
                session,
                dry_run=True,
                limit=limit,
                skus=skus,
                shop_code=shop_code,
                connection_id=connection_id,
                force=force,
            )
        return {"channel_code": channel, **summary}

    from app.jobs.channel_jobs import enqueue_shopify_product_push_job, job_to_dict

    with get_session() as session:
        connection = _resolve_shopify_compat_connection(session, connection_id)
        limit_skus = skus[:limit] if limit and skus else skus
        job = enqueue_shopify_product_push_job(
            session,
            channel_connection_id=connection["id"],
            native_connection_id=connection["native_id"],
            channel_code=_channel_code_for_connection(connection),
            skus=limit_skus,
            limit=limit if not skus else None,
            force=force,
            dry_run=False,
            shop_code=shop_code or connection.get("store_code"),
            notes="channel_pipeline_push",
        )
        data = job_to_dict(job)
        session.commit()
    return {
        "channel_code": channel,
        "status": "queued",
        "job_id": data["id"],
        "job": data,
    }


def _enqueue_remove_products_job(
    session,
    *,
    connection_id: int,
    payload: dict,
) -> dict:
    """Plan removals (DB-only) and enqueue a channel_job for remote deletes."""
    from app.jobs.channel_jobs import enqueue_channel_job, job_to_dict
    from db.channel_product_removal import plan_and_build_candidates, plan_options_from_payload

    connection = _connection_row(session, connection_id)
    if not connection:
        raise HTTPException(status_code=404, detail="Connection not found")
    channel_type = connection["channel_type"]
    if channel_type not in {"magento", "shopify"}:
        raise HTTPException(status_code=400, detail="remove_products supports magento and shopify only")

    plan, candidates = plan_and_build_candidates(
        session,
        channel_type,
        connection_id=connection_id,
        payload=payload,
    )
    options = plan_options_from_payload(payload)
    job = enqueue_channel_job(
        session,
        channel_connection_id=connection_id,
        channel_type=channel_type,
        native_connection_id=connection["native_id"],
        channel_code=_channel_code_for_connection(connection),
        job_type="remove_products",
        dry_run=False,
        mode="incremental",
        options=options,
    )
    job.total_count = len(candidates)
    job.notes = f"Planned {len(candidates)} product(s) for removal"
    job.result = {
        "_options": options,
        "plan_summary": {
            "candidate_count": plan.get("candidate_count"),
            "selection": plan.get("selection"),
            "remote_catalog_count": plan.get("remote_catalog_count"),
        },
    }
    data = job_to_dict(job)
    return {
        "status": "queued",
        "job_id": data["id"],
        "job": data,
        "plan": plan,
        "candidate_count": len(candidates),
    }


@app.post("/api/channel-pipelines/magento/remove-products")
async def magento_remove_products(payload: dict = Body(...)):
    """
    Plan or enqueue Magento product removals using DB-resolved product IDs.

    dry_run=true: synchronous plan only (no remote API calls).
    dry_run=false: enqueues channel_job remove_products; poll GET /api/channel-jobs/{id}.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    connection_id = payload.get("connection_id")
    if connection_id is None:
        raise HTTPException(status_code=400, detail="connection_id is required")
    dry_run = bool(payload.get("dry_run", True))
    magento_action = payload.get("magento_action")
    if not dry_run and not magento_action:
        raise HTTPException(
            status_code=400,
            detail="magento_action is required when dry_run=false ('hard' or 'disable')",
        )
    from db.channel_product_removal import plan_and_build_candidates

    try:
        with get_session() as session:
            if dry_run:
                plan, candidates = plan_and_build_candidates(
                    session,
                    "magento",
                    connection_id=int(connection_id),
                    payload=payload,
                )
                return {
                    "status": "ok",
                    "plan": plan,
                    "execution": {
                        "dry_run": True,
                        "would_delete": len(candidates),
                        "samples": [c.as_dict() for c in candidates[:25]],
                    },
                }
            result = _enqueue_remove_products_job(
                session,
                connection_id=int(connection_id),
                payload=payload,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return result


@app.post("/api/channel-pipelines/shopify/remove-products", status_code=202)
async def shopify_remove_products(payload: dict = Body(...)):
    """
    Plan or enqueue Shopify product removals using DB-resolved product GIDs.

    dry_run=true: synchronous plan only.
    dry_run=false: enqueues channel_job remove_products; poll GET /api/channel-jobs/{id}.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    dry_run = bool(payload.get("dry_run", True))
    connection_id = payload.get("connection_id")
    from db.channel_product_removal import plan_and_build_candidates

    try:
        with get_session() as session:
            native_connection_id = int(connection_id) if connection_id is not None else None
            if dry_run:
                plan, candidates = plan_and_build_candidates(
                    session,
                    "shopify",
                    connection_id=native_connection_id,
                    payload=payload,
                )
                return {
                    "status": "ok",
                    "plan": plan,
                    "execution": {
                        "dry_run": True,
                        "would_delete": len(candidates),
                        "samples": [c.as_dict() for c in candidates[:25]],
                    },
                }
            if connection_id is None:
                raise HTTPException(status_code=400, detail="connection_id is required for live removal")
            result = _enqueue_remove_products_job(
                session,
                connection_id=int(connection_id),
                payload=payload,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return result


@app.post("/api/channel-pipelines/{channel_code}/push-images", status_code=202)
async def channel_push_images(
    channel_code: str,
    background_tasks: BackgroundTasks,
    dry_run: bool = Query(True),
    limit: Optional[int] = Query(None),
    connection_id: Optional[int] = Query(None),
    skus: Optional[List[str]] = Body(None, embed=True),
    shop_code: Optional[str] = Body(None, embed=True),
    force: bool = Query(False),
):
    """Push master gallery images only — returns immediately; work is enqueued in background."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    channel = (channel_code or "").strip().lower()
    if channel not in CHANNEL_CODES:
        raise HTTPException(status_code=400, detail=f"Unsupported channel_code: {channel_code}")
    if channel == "plytix":
        raise HTTPException(status_code=501, detail="Plytix write-back is not implemented yet")
    if not connection_id:
        raise HTTPException(status_code=400, detail="connection_id is required")

    from app.jobs.image_push_enqueue import (
        enqueue_magento_image_push_background,
        enqueue_shopify_image_push_background,
        new_image_push_label,
    )

    label = new_image_push_label(channel)
    if channel == "magento":
        options: Dict[str, Any] = {
            "dry_run": dry_run,
            "use_master_catalog": True,
            "images_only": True,
        }
        if limit:
            options["limit_skus"] = skus[:limit] if skus else None
        elif skus:
            options["limit_skus"] = skus
        background_tasks.add_task(
            enqueue_magento_image_push_background,
            label,
            connection_id=connection_id,
            dry_run=dry_run,
            options=options,
        )
    else:
        background_tasks.add_task(
            enqueue_shopify_image_push_background,
            label,
            connection_id=connection_id,
            dry_run=dry_run,
            options={
                "limit": limit,
                "skus": skus,
                "shop_code": shop_code,
                "connection_id": connection_id,
                "force": force,
            },
        )

    return {
        "status": "accepted",
        "channel_code": channel,
        "mode": "images_only",
        "label": label,
        "dry_run": dry_run,
        "acceptance_url": f"/api/channel-pipelines/image-push/acceptance?label={label}",
        "message": "Accepted; poll acceptance_url then channel-jobs/{job_id} for progress.",
    }


@app.get("/api/channel-pipelines/image-push/acceptance")
async def channel_push_images_acceptance(label: str = Query(..., min_length=1)):
    """Poll until background enqueue finishes and job_id is available."""
    from app.jobs.image_push_enqueue import get_acceptance

    return get_acceptance(label.strip())


@app.get("/api/magento/sync-queue/{queue_id}")
async def magento_sync_queue_status(queue_id: int):
    """Poll Magento sync queue progress (used by dashboard image push)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        data = SqlAlchemyMagentoSyncQueueRepository(session).get_status(queue_id)
        if not data:
            raise HTTPException(status_code=404, detail="Queue item not found")
    return data


@app.get("/api/channel-pipelines/{channel_code}/images/preview")
async def channel_images_preview(
    channel_code: str,
    limit: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
    connection_id: Optional[int] = Query(None),
):
    """Preview SKUs/images that would be pushed in images-only mode."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    channel = (channel_code or "").strip().lower()
    if channel not in CHANNEL_CODES:
        raise HTTPException(status_code=400, detail=f"Unsupported channel_code: {channel_code}")
    from db.channel_image_push import build_image_push_items, summarize_image_push_scope

    with get_session() as session:
        summary = summarize_image_push_scope(
            session,
            channel,
            limit=limit,
            only_assigned=only_assigned,
            connection_id=connection_id,
        )
        items = build_image_push_items(
            session,
            channel,
            limit=limit,
            only_assigned=only_assigned,
            connection_id=connection_id,
        )
    return {
        "channel_code": channel,
        "mode": "images_only",
        **summary,
        "items": items[:200],
    }


@app.get("/api/channel-pipelines/{channel_code}/payloads")
async def channel_payload_preview(
    channel_code: str,
    limit: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
    connection_id: Optional[int] = Query(None),
):
    """Per-SKU composed payloads (master + overrides + aliases) for dashboard preview."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    try:
        with get_session() as session:
            payloads = build_channel_product_payloads(
                session,
                channel_code,
                limit=limit,
                only_assigned=only_assigned,
                connection_id=connection_id,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", "channel_code": channel_code, "count": len(payloads), "payloads": payloads}


@app.get("/api/channel-pipelines/{channel_code}/export")
async def channel_export_bundle_preview(
    channel_code: str,
    limit: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
    connection_id: Optional[int] = Query(None),
):
    """Payloads plus parent/child relation graph for structure-aware channels."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_exports import build_channel_export_bundle

    try:
        with get_session() as session:
            bundle = build_channel_export_bundle(
                session,
                channel_code,
                limit=limit,
                only_assigned=only_assigned,
                connection_id=connection_id,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", **bundle}


@app.get("/api/channel-pipelines/{channel_code}/export.csv")
async def channel_export_csv(
    channel_code: str,
    limit: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
    connection_id: Optional[int] = Query(None),
):
    """Provision CSV: one row per SKU with exactly the fields a push would send to the channel."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_exports import build_channel_export_bundle, flatten_export_bundle

    try:
        with get_session() as session:
            bundle = build_channel_export_bundle(
                session,
                channel_code,
                limit=limit,
                only_assigned=only_assigned,
                connection_id=connection_id,
            )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    rows = flatten_export_bundle(bundle)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    channel = bundle.get("channel_code") or channel_code
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": f"attachment; filename={channel}_provision_export.csv"},
    )


@app.post("/api/master-relations/sync-from-snapshots")
async def master_relations_sync_from_snapshots():
    """Bootstrap master_product_relation from current Plytix product_snapshot rows."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_relations import sync_relations_from_product_snapshots

    with get_session() as session:
        summary = sync_relations_from_product_snapshots(session)
        session.commit()
    return {"status": "ok", **summary}


@app.post("/api/master-associations/discover")
async def master_associations_discover(
    skus: Optional[str] = Query(None, description="Comma-separated SKU filter"),
    cap: int = Query(5, ge=4, le=6),
    dry_run: bool = Query(False),
    bidirectional: bool = Query(True),
):
    """Discover related / upsell / cross-sell associations (manual overrides preserved)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_associations import discover_associations

    sku_list = [part.strip() for part in (skus or "").split(",") if part.strip()] or None
    with get_session() as session:
        summary = discover_associations(
            session,
            skus=sku_list,
            cap=cap,
            dry_run=dry_run,
            bidirectional=bidirectional,
            source_label="api_discover",
        )
        if not dry_run:
            session.commit()
    return summary


@app.get("/api/channel-pipelines/{channel_code}/status")
async def channel_pipeline_status(
    channel_code: str,
    connection_id: Optional[int] = Query(None),
):
    """Publish-state summary for a channel: counts by status plus pending hash changes."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from sqlalchemy import func, select as sa_select

    from db.models import ChannelPublishState

    channel = (channel_code or "").strip().lower()
    if channel not in CHANNEL_CODES:
        raise HTTPException(status_code=400, detail=f"Unsupported channel_code: {channel_code}")
    with get_session() as session:
        counts = {
            str(status_value or "never_pushed"): int(count)
            for status_value, count in session.execute(
                sa_select(ChannelPublishState.last_status, func.count())
                .where(ChannelPublishState.channel_code == channel)
                .group_by(ChannelPublishState.last_status)
            ).all()
        }
        payloads = build_channel_product_payloads(session, channel, connection_id=connection_id)
        state_hashes = {
            sku: payload_hash
            for sku, payload_hash in session.execute(
                sa_select(ChannelPublishState.sku, ChannelPublishState.payload_hash).where(
                    ChannelPublishState.channel_code == channel
                )
            ).all()
        }
    pending = [p["sku"] for p in payloads if state_hashes.get(p["sku"]) != p["payload_hash"]]
    return {
        "status": "ok",
        "channel_code": channel,
        "assigned_skus": len(payloads),
        "publish_state_counts": counts,
        "pending_changes": len(pending),
        "sample_pending_skus": pending[:50],
    }


@app.get("/api/dashboard/publish-overview")
async def dashboard_publish_overview(
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    plytix_connection_id: Optional[int] = Query(None),
):
    """One lightweight payload for the Publish tab.

    This intentionally reads cached readiness instead of rebuilding full channel
    payloads for every card on page load. The readiness worker owns expensive
    SKU/link checks; the UI can request an explicit refresh when needed.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    from sqlalchemy import func, select as sa_select

    from db.channel_exports import resolve_push_skus
    from db.channel_image_push import summarize_image_push_scope
    from db.channel_readiness_cache import (
        cache_meta,
        get_readiness_cache_row,
        queue_readiness_refresh,
        readiness_api_payload,
        readiness_row_snapshot,
    )
    from db.master_product_images import master_image_summary
    from db.models import ChannelPublishState

    connection_by_channel = {
        "magento": magento_connection_id,
        "shopify": shopify_connection_id,
        "plytix": plytix_connection_id,
    }
    channels: Dict[str, Any] = {}
    with get_session() as session:
        for channel in sorted(CHANNEL_CODES):
            connection_id = connection_by_channel.get(channel)
            counts = {
                str(status_value or "never_pushed"): int(count)
                for status_value, count in session.execute(
                    sa_select(ChannelPublishState.last_status, func.count())
                    .where(ChannelPublishState.channel_code == channel)
                    .group_by(ChannelPublishState.last_status)
                ).all()
            }
            row = get_readiness_cache_row(
                session,
                channel,
                only_assigned=True,
                connection_id=connection_id,
                magento_connection_id=connection_id if channel == "magento" else None,
                shopify_connection_id=connection_id if channel == "shopify" else None,
            )
            meta = cache_meta(row)
            should_queue = meta["state"] in {"missing", "failed", "stale"}
            if should_queue and (row is None or row.status not in {"queued", "running"}):
                row = queue_readiness_refresh(
                    session,
                    channel,
                    only_assigned=True,
                    connection_id=connection_id,
                    magento_connection_id=connection_id if channel == "magento" else None,
                    shopify_connection_id=connection_id if channel == "shopify" else None,
                )
                meta = cache_meta(row)
            snapshot = readiness_row_snapshot(row)
            readiness = readiness_api_payload(snapshot=snapshot, auto_queue=should_queue)
            readiness["cache"] = meta

            publish_check = (readiness.get("checks") or {}).get("publish_state") or {}
            catalog_check = (readiness.get("checks") or {}).get("catalog") or {}
            assigned_skus = catalog_check.get("push_sku_count")
            if assigned_skus is None:
                assigned_skus = len(resolve_push_skus(session, channel, only_assigned=True))
            pending_changes = publish_check.get("pending_hash_changes")
            never_pushed = publish_check.get("never_pushed")
            if pending_changes is not None or never_pushed is not None:
                pending_changes = int(pending_changes or 0) + int(never_pushed or 0)
            elif not counts:
                pending_changes = assigned_skus
            else:
                pending_changes = 0

            channels[channel] = {
                "status": "ok",
                "channel_code": channel,
                "assigned_skus": assigned_skus,
                "publish_state_counts": counts,
                "pending_changes": pending_changes,
                "readiness": readiness,
            }

        image_connection_id = magento_connection_id
        image_readiness = None
        if image_connection_id:
            image_readiness = {
                "preview": summarize_image_push_scope(
                    session,
                    "magento",
                    only_assigned=True,
                    connection_id=image_connection_id,
                ),
                "summary": master_image_summary(session),
            }
        session.commit()

    return {
        "status": "ok",
        "channels": channels,
        "image_readiness": image_readiness,
    }


@app.get("/api/channel-pipelines/{channel_code}/readiness")
async def channel_pipeline_readiness(
    channel_code: str,
    connection_id: Optional[int] = Query(None),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
    refresh: bool = Query(False, description="Queue a background refresh if cache is missing or stale"),
):
    """Pre-push readiness from DB cache (refreshed every few hours in the background)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_exports import resolve_pipeline_channel_code
    from db.channel_readiness_cache import (
        cache_meta,
        get_readiness_cache_row,
        queue_readiness_refresh,
        readiness_api_payload,
        readiness_row_snapshot,
    )

    channel = resolve_pipeline_channel_code(channel_code)
    magento_id = magento_connection_id or connection_id
    shopify_id = shopify_connection_id or connection_id

    with get_session() as session:
        row = get_readiness_cache_row(
            session,
            channel,
            only_assigned=only_assigned,
            connection_id=connection_id,
            magento_connection_id=magento_id,
            shopify_connection_id=shopify_id,
        )
        meta = cache_meta(row)
        should_queue = refresh or meta["state"] in {"missing", "failed", "stale"}
        if should_queue and (row is None or row.status not in {"queued", "running"}):
            row = queue_readiness_refresh(
                session,
                channel,
                only_assigned=only_assigned,
                connection_id=connection_id,
                magento_connection_id=magento_id,
                shopify_connection_id=shopify_id,
                force=refresh,
            )
            meta = cache_meta(row)
        snapshot = readiness_row_snapshot(row)
        payload = readiness_api_payload(snapshot=snapshot, auto_queue=should_queue)
        payload["cache"] = meta
    return payload


@app.post("/api/channel-pipelines/{channel_code}/readiness/refresh", status_code=202)
async def channel_pipeline_readiness_refresh(
    channel_code: str,
    connection_id: Optional[int] = Query(None),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
):
    """Queue a background readiness recompute for this channel scope."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_exports import resolve_pipeline_channel_code
    from db.channel_readiness_cache import cache_meta, queue_readiness_refresh

    channel = resolve_pipeline_channel_code(channel_code)
    magento_id = magento_connection_id or connection_id
    shopify_id = shopify_connection_id or connection_id

    with get_session() as session:
        row = queue_readiness_refresh(
            session,
            channel,
            only_assigned=only_assigned,
            connection_id=connection_id,
            magento_connection_id=magento_id,
            shopify_connection_id=shopify_id,
            force=True,
        )
        meta = cache_meta(row)
    return {
        "status": "queued",
        "channel_code": channel,
        "cache": meta,
    }


@app.post("/api/channel-pipelines/readiness/refresh-all", status_code=202)
async def channel_pipeline_readiness_refresh_all():
    """Queue background readiness recompute for magento, shopify, and plytix (default scopes)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_readiness_cache import cache_meta, default_pipeline_scopes, queue_readiness_refresh

    queued = []
    with get_session() as session:
        for scope in default_pipeline_scopes():
            row = queue_readiness_refresh(session, force=True, **scope)
            queued.append({"channel_code": scope["channel_code"], "cache": cache_meta(row)})
        session.commit()
    return {"status": "queued", "channels": queued}


# ----------------------------
# Channel assignments (which SKU posts to which channel)
# ----------------------------

@app.get("/api/channel-assignments")
async def channel_assignments_list(
    channel_code: Optional[str] = Query(None),
    status: Optional[str] = Query(None),
    limit: Optional[int] = Query(None),
):
    """List sku→channel assignments."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = list_assignments(session, channel_code=channel_code, status=status, limit=limit)
    return {"status": "ok", "count": len(rows), "assignments": rows}


@app.get("/api/channel-assignments.csv")
async def channel_assignments_csv(
    channel_code: Optional[str] = Query(None),
    status: Optional[str] = Query(None),
):
    """Download sku→channel assignments as CSV."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = list_assignments(session, channel_code=channel_code, status=status)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=channel_assignments.csv"},
    )


@app.post("/api/channel-assignments/csv")
async def import_channel_assignments_csv(file: UploadFile = File(...)):
    """Upload sku→channel assignments from CSV (columns: sku, channel_code[, status] or sku, channels)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    with get_session() as session:
        stats = import_channel_assignment_dataframe(session, df)
        session.commit()
    return {"status": "ok", "file": file.filename, **stats}


@app.post("/api/channel-assignments/assign")
async def channel_assignments_assign(
    skus: List[str] = Body(..., embed=True),
    channel_code: str = Body(..., embed=True),
    reason: str = Body("manual", embed=True),
):
    """Assign SKUs to a channel (activates publishing for them)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        stats = assign_skus(session, skus=skus, channel_code=channel_code, reason=reason)
        session.commit()
    return {"status": "ok", "channel_code": channel_code, **stats}


@app.post("/api/channel-assignments/assign-all-active")
async def channel_assignments_assign_all_active(
    channel_code: Optional[str] = Body(None, embed=True),
    channels: Optional[List[str]] = Body(None, embed=True),
    reason: str = Body("dashboard_assign_all", embed=True),
):
    """Assign every active master SKU to one or more channels (CLI link_master_skus --assign-all)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_link import assign_all_active_masters

    selected = [str(c).strip().lower() for c in (channels or ([channel_code] if channel_code else [])) if str(c).strip()]
    if not selected:
        raise HTTPException(status_code=400, detail="channel_code or channels is required")
    with get_session() as session:
        summary = assign_all_active_masters(session, channels=selected, reason=reason)
        session.commit()
    return {"status": "ok", **summary}


@app.post("/api/channel-assignments/assign-matched")
async def channel_assignments_assign_matched(
    channel_code: str = Body(..., embed=True),
    connection_id: Optional[int] = Body(None, embed=True),
    reason: str = Body("dashboard_assign_matched", embed=True),
):
    """Assign master SKUs that are already linked or appear in reconcile suggestions."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        report = _reconciliation_report(session, channel_code, connection_id, only_assigned=False)
        skus: set[str] = set()
        for row in report.get("mapped") or []:
            if row.get("master_sku"):
                skus.add(str(row["master_sku"]).strip())
        for row in report.get("suggestions") or []:
            if row.get("master_sku"):
                skus.add(str(row["master_sku"]).strip())
        if not skus:
            return {
                "status": "ok",
                "channel_code": channel_code,
                "assigned": 0,
                "message": "No linked or suggested masters — run Reconcile or Link from remote first.",
            }
        stats = assign_skus(session, skus=sorted(skus), channel_code=channel_code, reason=reason)
        session.commit()
    return {"status": "ok", "channel_code": channel_code, "matched": len(skus), **stats}


@app.post("/api/channel-catalog/scan-link")
async def channel_catalog_scan_link(
    channels: Optional[List[str]] = Body(None, embed=True),
    assign_all: bool = Body(False, embed=True),
    dry_run: bool = Body(False, embed=True),
    magento_connection_id: Optional[int] = Body(None, embed=True),
    shopify_connection_id: Optional[int] = Body(None, embed=True),
):
    """Scan remote catalogs and link SKUs to master (CLI link_master_skus)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_link import scan_and_link_channels

    with get_session() as session:
        summary = scan_and_link_channels(
            session,
            channels=channels,
            assign_all=assign_all,
            dry_run=dry_run,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
        )
        if not dry_run:
            session.commit()
    return summary


@app.post("/api/channel-assignments/assign-by-filter")
async def channel_assignments_assign_by_filter(payload: dict = Body(...)):
    """Assign all master SKUs matching filters to a channel."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import resolve_filtered_master_skus

    channel_code = str(payload.get("channel_code") or "").strip()
    if not channel_code:
        raise HTTPException(status_code=400, detail="channel_code is required")
    reason = str(payload.get("reason") or "filter_assign")
    dry_run = bool(payload.get("dry_run", False))
    try:
        with get_session() as session:
            skus = resolve_filtered_master_skus(
                session,
                channel_code=None,
                only_assigned=bool(payload.get("only_assigned", False)),
                category_l1=payload.get("category_l1"),
                category_l2=payload.get("category_l2"),
                category_l3=payload.get("category_l3"),
                collection=payload.get("collection"),
                product_family=payload.get("product_family"),
                search=payload.get("search"),
                master_filters=payload.get("master_filters"),
                limit=payload.get("limit"),
            )
            if not skus:
                return {"status": "ok", "channel_code": channel_code, "matched": 0, "assigned": 0, "dry_run": dry_run}
            if dry_run:
                return {
                    "status": "ok",
                    "channel_code": channel_code,
                    "matched": len(skus),
                    "would_assign": len(skus),
                    "samples": skus[:25],
                    "dry_run": True,
                }
            stats = assign_skus(session, skus=skus, channel_code=channel_code, reason=reason)
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "channel_code": channel_code, "matched": len(skus), **stats}


@app.post("/api/channel-assignments/remove-by-filter")
async def channel_assignments_remove_by_filter(payload: dict = Body(...)):
    """Remove channel assignments for all master SKUs matching filters."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import resolve_filtered_master_skus

    channel_code = str(payload.get("channel_code") or "").strip()
    if not channel_code:
        raise HTTPException(status_code=400, detail="channel_code is required")
    reason = str(payload.get("reason") or "filter_remove")
    dry_run = bool(payload.get("dry_run", False))
    try:
        with get_session() as session:
            skus = resolve_filtered_master_skus(
                session,
                channel_code=channel_code,
                only_assigned=bool(payload.get("only_assigned", True)),
                category_l1=payload.get("category_l1"),
                category_l2=payload.get("category_l2"),
                category_l3=payload.get("category_l3"),
                collection=payload.get("collection"),
                product_family=payload.get("product_family"),
                search=payload.get("search"),
                master_filters=payload.get("master_filters"),
                limit=payload.get("limit"),
            )
            if not skus:
                return {"status": "ok", "channel_code": channel_code, "matched": 0, "removed": 0, "dry_run": dry_run}
            if dry_run:
                return {
                    "status": "ok",
                    "channel_code": channel_code,
                    "matched": len(skus),
                    "would_remove": len(skus),
                    "samples": skus[:25],
                    "dry_run": True,
                }
            stats = remove_skus(session, skus=skus, channel_code=channel_code, reason=reason)
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "channel_code": channel_code, "matched": len(skus), **stats}


@app.post("/api/channel-assignments/remove")
async def channel_assignments_remove(
    skus: List[str] = Body(..., embed=True),
    channel_code: str = Body(..., embed=True),
    reason: str = Body("manual_removal", embed=True),
):
    """Remove SKUs from a channel (stops future publishing; remote products are not deleted)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        stats = remove_skus(session, skus=skus, channel_code=channel_code, reason=reason)
        session.commit()
    return {"status": "ok", "channel_code": channel_code, **stats}


@app.post("/api/channel-attributes/provision")
async def channel_attributes_provision(
    channel_code: str = Body(..., embed=True),
    connection_id: Optional[int] = Body(None, embed=True),
    shop_code: Optional[str] = Body(None, embed=True),
    dry_run: bool = Body(False, embed=True),
):
    """Create Magento attributes / Shopify metafields for mappings marked Create new."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_attribute_provision import provision_channel_attributes

    with get_session() as session:
        result = provision_channel_attributes(
            session,
            channel_code,
            connection_id=connection_id,
            shop_code=shop_code,
            dry_run=dry_run,
        )
        if not dry_run:
            session.commit()
    if result.get("status") == "failed":
        raise HTTPException(status_code=400, detail=result.get("error", "Provision failed"))
    return {"status": "ok", **result}


# ----------------------------
# Channel SKU mapping (master SKU ↔ channel-specific SKU)
# ----------------------------


@app.get("/api/channel-sku-mappings")
async def channel_sku_mappings_list(
    channel_code: Optional[str] = Query(None),
    connection_id: Optional[int] = Query(None),
    master_sku: Optional[str] = Query(None),
    status: Optional[str] = Query("active"),
    limit: Optional[int] = Query(None),
):
    """List master→channel SKU mappings."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_mapping import list_mappings

    with get_session() as session:
        rows = list_mappings(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            master_sku=master_sku,
            status=status,
            limit=limit,
        )
    return {"status": "ok", "count": len(rows), "mappings": rows}


@app.get("/api/channel-sku-mappings.csv")
async def channel_sku_mappings_csv(
    channel_code: Optional[str] = Query(None),
    status: Optional[str] = Query("active"),
):
    from db.channel_sku_mapping import list_mappings

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = list_mappings(session, channel_code=channel_code, status=status)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=channel_sku_mappings.csv"},
    )


@app.post("/api/channel-sku-mappings/upsert")
async def channel_sku_mappings_upsert(
    master_sku: str = Body(...),
    channel_sku: str = Body(...),
    channel_code: Optional[str] = Body(None),
    connection_id: Optional[int] = Body(None),
    remote_id: Optional[str] = Body(None),
    notes: Optional[str] = Body(None),
):
    """Create or update a single SKU mapping from the dashboard form."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_mapping import upsert_single_mapping

    try:
        with get_session() as session:
            row = upsert_single_mapping(
                session,
                master_sku=master_sku,
                channel_sku=channel_sku,
                channel_code=channel_code,
                connection_id=connection_id,
                remote_id=remote_id,
                notes=notes,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", "mapping": row}


@app.post("/api/channel-sku-mappings/csv")
async def channel_sku_mappings_import_csv(file: UploadFile = File(...)):
    """Upload SKU mappings CSV: sku, connection_id|channel_id|channel_code, channel_sku."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    from db.channel_sku_mapping import import_channel_sku_mapping_dataframe

    with get_session() as session:
        stats = import_channel_sku_mapping_dataframe(session, df)
        session.commit()
    return {"status": "ok", **stats}


def _shopify_remote_catalog_for_readiness(session, connection_id: Optional[int], *, only_assigned: bool) -> dict:
    """Assigned-channel SKU subset from Shopify Admin API (avoids full-catalog timeouts)."""
    from db.channel_sku_mapping import fetch_shopify_remote_catalog_for_readiness

    try:
        return fetch_shopify_remote_catalog_for_readiness(
            session,
            connection_id=connection_id,
            only_assigned=only_assigned,
        )
    except ValueError as exc:
        raise HTTPException(status_code=409 if "disabled" in str(exc).lower() else 404, detail=str(exc))
    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(status_code=502, detail=f"Failed to fetch Shopify catalog: {exc}")


def _shopify_remote_catalog(session, connection_id: Optional[int]) -> dict:
    """Live SKU → product GID map from the Shopify Admin API for reconciliation."""
    from db.channel_sku_mapping import fetch_shopify_remote_catalog

    try:
        return fetch_shopify_remote_catalog(session, connection_id=connection_id)
    except ValueError as exc:
        raise HTTPException(status_code=409 if "disabled" in str(exc).lower() else 404, detail=str(exc))
    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(status_code=502, detail=f"Failed to fetch Shopify catalog: {exc}")


def _reconciliation_report(session, channel_code: str, connection_id: Optional[int], only_assigned: bool) -> dict:
    from db.channel_exports import resolve_pipeline_channel_code
    from db.channel_sku_mapping import build_reconciliation_report

    channel = resolve_pipeline_channel_code(channel_code)
    remote_catalog = None
    remote_source = None
    if channel == "shopify":
        remote_catalog = _shopify_remote_catalog(session, connection_id)
        remote_source = "shopify_admin_api"
    return build_reconciliation_report(
        session,
        channel,
        connection_id=connection_id,
        only_assigned=only_assigned,
        remote_catalog=remote_catalog,
        remote_source=remote_source,
    )


@app.get("/api/channel-sku-mappings/reconcile")
async def channel_sku_mappings_reconcile(
    channel_code: str = Query(...),
    connection_id: Optional[int] = Query(None),
    only_assigned: bool = Query(True),
):
    """Gap report: mapped / suggested / unmapped master SKUs vs remote catalog (Shopify is queried live)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    try:
        with get_session() as session:
            report = _reconciliation_report(session, channel_code, connection_id, only_assigned)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", **report}


@app.post("/api/channel-sku-mappings/apply-suggestions")
async def channel_sku_mappings_apply_suggestions(
    channel_code: str = Body(..., embed=True),
    suggestions: Optional[List[Dict[str, Any]]] = Body(None, embed=True),
    connection_id: Optional[int] = Body(None, embed=True),
    only_assigned: bool = Body(True, embed=True),
):
    """Persist suggested mappings from a reconcile run (or an explicit suggestion list)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_mapping import apply_suggestions

    with get_session() as session:
        items = suggestions
        if not items:
            report = _reconciliation_report(session, channel_code, connection_id, only_assigned)
            items = [
                {**row, "channel_code": channel_code, "connection_id": connection_id}
                for row in report.get("suggestions", [])
            ]
            items.extend(
                {**row, "connection_id": connection_id}
                for row in report.get("remote_id_backfill", [])
            )
        else:
            items = [
                {**row, "connection_id": row.get("connection_id") or connection_id}
                for row in items
            ]
        upserted = apply_suggestions(session, items or [])
        session.commit()
    return {"status": "ok", "channel_code": channel_code, "upserted": upserted}


@app.post("/api/channel-sku-mappings/link-from-remote")
async def channel_sku_mappings_link_from_remote(
    channel_code: str = Body(..., embed=True),
    connection_id: Optional[int] = Body(None, embed=True),
    only_assigned: bool = Body(True, embed=True),
    dry_run: bool = Body(False, embed=True),
):
    """Prefix-aware bulk link: match assigned masters to remote catalog SKUs + store remote_id."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_exports import resolve_pipeline_channel_code
    from app.jobs.channel_jobs import enqueue_channel_job, job_to_dict
    from db.channel_sku_mapping import link_mappings_from_remote

    try:
        with get_session() as session:
            channel = resolve_pipeline_channel_code(channel_code)
            compat_connection_id = connection_id
            native_connection_id = None
            channel_type = channel
            if compat_connection_id:
                connection = _connection_row(session, compat_connection_id)
                if connection:
                    channel_type = connection["channel_type"]
                    native_connection_id = connection["native_id"]
            if not dry_run:
                if not compat_connection_id:
                    raise ValueError("connection_id is required for queued link-from-remote")
                job = enqueue_channel_job(
                    session,
                    channel_connection_id=compat_connection_id,
                    channel_type=channel_type,
                    native_connection_id=native_connection_id or compat_connection_id,
                    channel_code=channel,
                    job_type="sku_link",
                    dry_run=False,
                    mode="full",
                    notes="sku_link",
                    options={"only_assigned": only_assigned},
                )
                data = job_to_dict(job)
                session.commit()
                return {"status": "queued", "job_id": data["id"], "job": data}

            remote_catalog = None
            if channel == "shopify":
                remote_catalog = _shopify_remote_catalog(session, connection_id)
            result = link_mappings_from_remote(
                session,
                channel_code,
                connection_id=connection_id,
                only_assigned=only_assigned,
                remote_catalog=remote_catalog,
                dry_run=dry_run,
            )
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", **result}


@app.post("/api/channel-sku-mappings/bootstrap-identity")
async def channel_sku_mappings_bootstrap_identity(
    channel_code: str = Body(..., embed=True),
    only_assigned: bool = Body(True, embed=True),
):
    """Create 1:1 mappings where channel_sku == master_sku (starting point for exact-match catalogs)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_mapping import bootstrap_identity_mappings

    try:
        with get_session() as session:
            stats = bootstrap_identity_mappings(session, channel_code, only_assigned=only_assigned)
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", "channel_code": channel_code, **stats}


@app.post("/api/channel-sku-mappings/bootstrap-magento")
async def channel_sku_mappings_bootstrap_magento(
    connection_id: int = Body(..., embed=True),
    only_assigned: bool = Body(True, embed=True),
):
    """Bootstrap Magento mappings from magento_catalog_state (exact SKU + source_item matches)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_mapping import bootstrap_from_magento_catalog

    with get_session() as session:
        stats = bootstrap_from_magento_catalog(session, connection_id, only_assigned=only_assigned)
        session.commit()
    return {"status": "ok", "channel_code": "magento", "connection_id": connection_id, **stats}


@app.post("/api/channel-sku-mappings/rename-prefix")
async def channel_sku_mappings_rename_prefix(
    channel_code: str = Body(..., embed=True),
    connection_id: Optional[int] = Body(None, embed=True),
    limit: Optional[int] = Body(50, embed=True),
    dry_run: bool = Body(True, embed=True),
    linked_only: bool = Body(True, embed=True),
    skus: Optional[List[str]] = Body(None, embed=True),
    magento_connection_id: Optional[int] = Body(None, embed=True),
    shopify_connection_id: Optional[int] = Body(None, embed=True),
):
    """Rename linked remote SKUs to prefix-rule targets (Shopify/Magento; no product push)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_exports import resolve_pipeline_channel_code
    from db.channel_sku_rename import apply_channel_sku_renames
    from db.compat_connections import decode_compat_connection_id

    channel = resolve_pipeline_channel_code(channel_code)
    magento_native = magento_connection_id
    shopify_compat = None
    if connection_id is not None:
        try:
            channel_type, native_id = decode_compat_connection_id(connection_id)
            if channel_type == "magento":
                magento_native = magento_native or native_id
            elif channel_type == "shopify":
                shopify_compat = connection_id
        except ValueError:
            if channel == "magento":
                magento_native = magento_native or connection_id
            elif channel == "shopify":
                shopify_compat = connection_id
    if channel == "magento" and magento_native is None and connection_id:
        magento_native = connection_id
    if channel == "shopify" and shopify_compat is None and connection_id:
        shopify_compat = connection_id

    batch_limit = max(1, min(int(limit or 50), 250))
    with get_session() as session:
        result = apply_channel_sku_renames(
            session,
            channels=[channel],
            only_assigned=False,
            linked_only=linked_only,
            dry_run=dry_run,
            skus=skus,
            limit=batch_limit,
            magento_connection_id=magento_native,
            shopify_connection_id=shopify_compat,
        )
        if not dry_run:
            session.commit()
    return {"status": "ok", **result}


@app.post("/api/magento/sync-state/align")
async def magento_sync_state_align(
    connection_id: int = Body(..., embed=True),
    dry_run: bool = Body(True, embed=True),
    only_assigned: bool = Body(True, embed=True),
):
    """Re-key magento_sync_state rows from master_sku to channel_sku for master-catalog push."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.compat_connections import decode_compat_connection_id
    from db.magento_sync_state_align import align_magento_sync_state_keys

    channel_type, native_id = decode_compat_connection_id(connection_id)
    if channel_type != "magento":
        raise HTTPException(status_code=400, detail="connection_id must be a Magento connection")
    with get_session() as session:
        result = align_magento_sync_state_keys(
            session,
            native_id,
            dry_run=dry_run,
            only_assigned=only_assigned,
        )
        if not dry_run:
            session.commit()
    return {"status": "ok", **result}


@app.get("/api/magento/connections/{connection_id}/debug/sku-push-status")
async def magento_debug_sku_push_status(
    connection_id: int,
    sku: str = Query(..., description="Master or channel SKU, e.g. RTA-GSW-OC3384"),
    live_magento: bool = Query(
        False,
        description="If true, also GET the product from Magento (entity id / existence)",
    ),
):
    """Diagnose why a SKU (especially RTA) keeps getting pushed, and surface duplicate-risk signals.

    Returns sync_state vs catalog_state for master/channel/RTA key variants, assignment,
    and actionable risks (missing state keys, media-forced upserts, RTA mirror).
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    sku = str(sku or "").strip()
    if not sku:
        raise HTTPException(status_code=400, detail="sku required")

    from db.compat_connections import decode_compat_connection_id
    from db.magento_sku_push_diagnostics import diagnose_magento_sku_push

    channel_type, native_id = decode_compat_connection_id(connection_id)
    if channel_type != "magento":
        raise HTTPException(status_code=400, detail="connection_id must be a Magento connection")

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        conn = conn_repo.get_for_sync(native_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")
        result = diagnose_magento_sku_push(session, native_id, sku)

        magento_live: Optional[Dict[str, Any]] = None
        if live_magento:
            oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
            api = MagentoRestClient(oauth)
            product = api.get_product(result.get("planner_sku") or sku)
            magento_live = {
                "queried_sku": result.get("planner_sku") or sku,
                "exists": product is not None,
                "entity_id": (product or {}).get("id"),
                "sku": (product or {}).get("sku"),
                "type_id": (product or {}).get("type_id"),
                "status": (product or {}).get("status"),
                "updated_at": (product or {}).get("updated_at"),
            }
            # Also probe input SKU if different (detects catalog oddities).
            if (result.get("planner_sku") or sku) != sku:
                alt = api.get_product(sku)
                magento_live["input_sku_probe"] = {
                    "exists": alt is not None,
                    "entity_id": (alt or {}).get("id"),
                    "sku": (alt or {}).get("sku"),
                }

        return {"status": "ok", **result, "magento_live": magento_live}


# ----------------------------
# Channel SKU prefix mapping (master prefix → channel prefix, e.g. HPW* → HD-CW*)
# ----------------------------


@app.get("/api/channel-sku-prefix-mappings")
async def channel_sku_prefix_mappings_list(
    channel_code: Optional[str] = Query(None),
    connection_id: Optional[int] = Query(None),
    status: Optional[str] = Query("active"),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_prefix_mapping import list_prefix_mappings

    with get_session() as session:
        rows = list_prefix_mappings(
            session,
            channel_code=channel_code,
            connection_id=connection_id,
            status=status,
        )
    return {"status": "ok", "count": len(rows), "rules": rows}


@app.get("/api/channel-sku-prefix-mappings.csv")
async def channel_sku_prefix_mappings_csv(
    channel_code: Optional[str] = Query(None),
    status: Optional[str] = Query("active"),
):
    from db.channel_sku_prefix_mapping import list_prefix_mappings

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = list_prefix_mappings(session, channel_code=channel_code, status=status)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=channel_sku_prefix_mappings.csv"},
    )


@app.post("/api/channel-sku-prefix-mappings/csv")
async def channel_sku_prefix_mappings_import_csv(
    file: UploadFile = File(...),
    channel_code: Optional[str] = Query(None),
):
    """Upload prefix rules CSV: shopify/channel_prefix, master/master_prefix."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    from db.channel_sku_prefix_mapping import import_prefix_mapping_dataframe

    with get_session() as session:
        stats = import_prefix_mapping_dataframe(session, df, default_channel_code=channel_code)
        session.commit()
    return {"status": "ok", **stats}


@app.get("/api/channel-sku-prefix-mappings/preview")
async def channel_sku_prefix_mappings_preview(
    master_sku: str = Query(...),
    channel_code: str = Query("shopify"),
    connection_id: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_prefix_mapping import preview_prefix_resolution

    with get_session() as session:
        result = preview_prefix_resolution(
            session,
            master_sku,
            channel_code,
            connection_id=connection_id,
        )
    return {"status": "ok", **result}


@app.post("/api/channel-sku-prefix-mappings/bootstrap-shopify")
async def channel_sku_prefix_mappings_bootstrap_shopify(
    connection_id: Optional[int] = Body(None, embed=True),
):
    """Seed the 11 default Shopify prefix rules (HPW→HD-CW, ASW→HD-SNW, …)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_sku_prefix_mapping import bootstrap_shopify_prefix_defaults

    with get_session() as session:
        stats = bootstrap_shopify_prefix_defaults(session, connection_id=connection_id)
        session.commit()
    return {"status": "ok", "channel_code": "shopify", **stats}


# ----------------------------
# Master overrides (SEO and other downstream-winning field overrides)
# ----------------------------

@app.get("/api/master-overrides.csv")
async def master_overrides_csv():
    """Download all master product overrides (SEO etc.) as CSV."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        rows = export_master_overrides(session)
    df = pd.DataFrame(rows)
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=master_product_overrides.csv"},
    )


@app.post("/api/master-overrides/csv")
async def import_master_overrides_csv(
    file: UploadFile = File(...),
    source_label: str = Query("override"),
    channel_code: str = Query("", description="Empty = applies to all channels"),
):
    """Upload overrides from CSV. Long format (sku, attribute_code, value[, channel_code]) or
    wide format (sku + one column per field, e.g. SKU, SEO Title, SEO Description)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    with get_session() as session:
        stats = import_master_override_dataframe(
            session, df, source_label=source_label, default_channel_code=channel_code
        )
        session.commit()
    return {"status": "ok", "file": file.filename, "source_label": source_label, **stats}


@app.post("/api/master-overrides/set")
async def master_overrides_set(
    sku: str = Body(..., embed=True),
    attribute_code: str = Body(..., embed=True),
    value: Optional[str] = Body(None, embed=True),
    channel_code: str = Body("", embed=True),
    source_label: str = Body("manual", embed=True),
    is_active: bool = Body(True, embed=True),
):
    """Upsert a single override (set is_active=false to disable it)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    try:
        with get_session() as session:
            row = set_master_override(
                session,
                sku=sku,
                attribute_code=attribute_code,
                value=value,
                channel_code=channel_code,
                source_label=source_label,
                is_active=is_active,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", **row}


# ----------------------------
# Shopify connections (credentials + kill switch)
# ----------------------------

@app.post("/api/shopify/connections")
async def shopify_create_connection(payload: dict = Body(...)):
    """Create or update a Shopify connection by shop_code (stores Admin API credentials)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from shopify.connections import connection_summary, upsert_connection

    try:
        with get_session() as session:
            row = upsert_connection(session, payload)
            session.commit()
            result = connection_summary(row)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    return {"status": "ok", "connection": result}


@app.get("/api/shopify/connections")
async def shopify_list_connections():
    """List Shopify connections (secrets are never returned)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from shopify.connections import list_connections as list_shopify_connections

    with get_session() as session:
        rows = list_shopify_connections(session)
    return {"status": "ok", "connections": rows}


@app.patch("/api/shopify/connections/{connection_id}")
async def shopify_update_connection(connection_id: int, payload: dict = Body(...)):
    """Partial update; set status='disabled' as the kill switch, 'active' to re-enable."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from shopify.connections import connection_summary, update_connection

    try:
        with get_session() as session:
            row = update_connection(session, connection_id, payload)
            session.commit()
            result = connection_summary(row)
    except LookupError:
        raise HTTPException(status_code=404, detail="Connection not found")
    return {"status": "ok", "connection": result}


@app.post("/api/shopify/connections/{connection_id}/verify")
async def shopify_verify_connection(connection_id: int):
    """Verify Shopify credentials with a lightweight shop query."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from shopify.connections import verify_connection

    try:
        with get_session() as session:
            result = verify_connection(session, connection_id)
            session.commit()
    except LookupError:
        raise HTTPException(status_code=404, detail="Connection not found")
    return result


@app.post("/api/master-skus/import/csv")
async def import_master_skus_csv(
    file: UploadFile = File(...),
    source_label: str = Query("master_sku"),
    preview_only: bool = Query(
        False,
        description="No-write preview: analyze CSV and create intent run without persisting SKUs",
    ),
    preview: bool = Query(
        False,
        description="Staged import: persist SKUs + intent run, without downstream provision",
    ),
    resolve_registries: bool = Query(False, description="Resolve registry FKs during import"),
):
    """Import a clean Master SKU CSV into DB-owned canonical product tables."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if preview_only and preview:
        raise HTTPException(status_code=400, detail="Use preview_only or preview, not both")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    with get_session() as session:
        if preview_only:
            from db.master_catalog_intent import create_intent_preview_only_from_dataframe

            result = create_intent_preview_only_from_dataframe(session, df, source_label=source_label)
            session.commit()
            return {"status": "ok", "source": "master_sku_preview_only", **result}
        if preview:
            from db.master_catalog_intent import create_intent_staged_import_from_dataframe

            result = create_intent_staged_import_from_dataframe(session, df, source_label=source_label)
            session.commit()
            return {"status": "ok", "source": "master_sku_staged_import", **result}
        stats = import_master_sku_dataframe(
            session,
            df,
            source_label=source_label,
            resolve_registries=resolve_registries,
        )
        session.commit()
    return {"status": "ok", "source": "master_sku", "source_label": source_label, **stats}


@app.post("/api/master-skus/import/preview")
async def import_master_skus_preview_only(
    file: UploadFile = File(...),
    source_label: str = Query("master_sku_preview"),
):
    """Analyze a Master SKU CSV without persisting products (alias for preview_only=true)."""
    return await import_master_skus_csv(file=file, source_label=source_label, preview_only=True, preview=False)


@app.post("/api/master-taxonomy/backfill")
async def master_taxonomy_backfill(dry_run: bool = Query(True)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import backfill_master_taxonomy_from_products

    with get_session() as session:
        stats = backfill_master_taxonomy_from_products(session, dry_run=dry_run)
        if not dry_run:
            session.commit()
    return {"status": "ok", **stats}


@app.get("/api/master-taxonomy/tree")
async def master_taxonomy_tree(
    node_kind: Optional[str] = Query(None, description="hub, category, or collection"),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_hierarchy import list_taxonomy_tree

    with get_session() as session:
        payload = list_taxonomy_tree(
            session,
            node_kind=node_kind,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
        )
    return {"status": "ok", **payload}


@app.post("/api/master-taxonomy/build-hierarchy")
async def master_taxonomy_build_hierarchy(dry_run: bool = Query(False)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_hierarchy import build_hierarchy_from_master_catalog

    with get_session() as session:
        stats = build_hierarchy_from_master_catalog(session, dry_run=dry_run, allow_invent=True)
        if not dry_run:
            session.commit()
    return {"status": "ok", **stats}


@app.post("/api/master-taxonomy/sync-channel-links")
async def master_taxonomy_sync_channel_links(
    dry_run: bool = Query(False),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
    write_listing_path_targets: bool = Query(True),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_hierarchy import sync_channel_links_from_registries

    with get_session() as session:
        stats = sync_channel_links_from_registries(
            session,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
            dry_run=dry_run,
            write_listing_path_targets=write_listing_path_targets,
        )
        if not dry_run:
            session.commit()
    return {"status": "ok", **stats}


@app.post("/api/master-taxonomy/bootstrap")
async def master_taxonomy_bootstrap(
    payload: dict = Body({}),
    background: bool = Query(True),
):
    """Backfill registries, build hierarchy, sync channel IDs; optionally push to channels."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    params = {
        "dry_run": bool(payload.get("dry_run", False)),
        "magento_connection_id": payload.get("magento_connection_id"),
        "shopify_connection_id": payload.get("shopify_connection_id"),
        "run_backfill": bool(payload.get("run_backfill", True)),
        "run_build_hierarchy": bool(payload.get("run_build_hierarchy", True)),
        "run_sync_links": bool(payload.get("run_sync_links", True)),
        "push_to_channels": bool(payload.get("push_to_channels", False)),
        "create_missing": bool(payload.get("create_missing", True)),
        "include_magento": bool(payload.get("include_magento", True)),
        "include_shopify": bool(payload.get("include_shopify", True)),
        "allow_taxonomy_invent": bool(payload.get("allow_taxonomy_invent", True)),
    }
    if background and not params["dry_run"]:
        from app.jobs.master_taxonomy_enqueue import start_master_taxonomy_job

        job_id = start_master_taxonomy_job("bootstrap", params)
        return {
            "status": "queued",
            "job_id": job_id,
            "poll_url": f"/api/master-taxonomy/jobs/{job_id}",
        }
    from app.jobs.master_taxonomy_enqueue import _execute_bootstrap

    result = _execute_bootstrap(params)
    return {"status": "ok", **result}


@app.post("/api/master-taxonomy/nodes")
async def master_taxonomy_nodes_upsert(payload: dict = Body(...)):
    """Create or update a hierarchy node; optional immediate channel sync."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_sync import build_node_channel_sync_params, taxonomy_sync_node_ids, upsert_taxonomy_node

    sync_channels = payload.pop("sync_channels", None)
    if sync_channels is None:
        sync_channels = True
    else:
        sync_channels = bool(sync_channels)
    background_sync = bool(payload.pop("background_sync", True))
    try:
        with get_session() as session:
            native_magento = (
                _resolve_native_connection_id(session, payload.get("magento_connection_id"))
                if payload.get("magento_connection_id")
                else _default_native_connection_id(session, "magento")
            )
            native_shopify = (
                _resolve_native_connection_id(session, payload.get("shopify_connection_id"))
                if payload.get("shopify_connection_id")
                else _default_native_connection_id(session, "shopify")
            )
            upserted = upsert_taxonomy_node(session, payload, allow_invent=True)
            node_id = upserted["node"]["id"]
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    if not sync_channels:
        return {"status": "ok", **upserted}

    push_params = build_node_channel_sync_params(
        node_id,
        payload,
        node_ids=taxonomy_sync_node_ids(node_id, upserted),
        include_magento=bool(payload.get("include_magento", True)),
        include_shopify=bool(payload.get("include_shopify", True)),
    )
    if background_sync:
        from app.jobs.master_taxonomy_enqueue import start_master_taxonomy_job

        job_id = start_master_taxonomy_job("push_nodes", push_params)
        return {
            "status": "ok",
            **upserted,
            "channel_sync": {"status": "queued", "job_id": job_id, "poll_url": f"/api/master-taxonomy/jobs/{job_id}"},
        }

    from app.jobs.master_taxonomy_enqueue import _execute_push_nodes

    push_result = _execute_push_nodes(push_params)
    return {"status": "ok", **upserted, "channel_sync": push_result}


@app.post("/api/master-taxonomy/nodes/sync")
async def master_taxonomy_nodes_sync(payload: dict = Body({}), background: bool = Query(True)):
    """Push hierarchy nodes to Magento categories and Shopify collections."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_sync import coerce_taxonomy_node_ids

    params = {
        "node_ids": coerce_taxonomy_node_ids(payload.get("node_ids")),
        "magento_connection_id": payload.get("magento_connection_id"),
        "shopify_connection_id": payload.get("shopify_connection_id"),
        "dry_run": bool(payload.get("dry_run", False)),
        "create_missing": bool(payload.get("create_missing", True)),
        "include_magento": bool(payload.get("include_magento", True)),
        "include_shopify": bool(payload.get("include_shopify", True)),
        "shopify_handle_overrides": payload.get("shopify_handle_overrides"),
        "force_create_magento": bool(payload.get("force_create_magento", False)),
        # Default true so shopping-facet sync also creates Magento L2 children.
        "include_descendants": bool(payload.get("include_descendants", True)),
    }
    if background:
        from app.jobs.master_taxonomy_enqueue import start_master_taxonomy_job

        job_id = start_master_taxonomy_job("push_nodes", params)
        return {"status": "queued", "job_id": job_id, "poll_url": f"/api/master-taxonomy/jobs/{job_id}"}
    from app.jobs.master_taxonomy_enqueue import _execute_push_nodes

    result = _execute_push_nodes(params)
    return {"status": "ok", **result}


@app.post("/api/master-taxonomy/nodes/{node_id}/assign-skus")
async def master_taxonomy_node_assign_skus(node_id: int, payload: dict = Body({}), background: bool = Query(True)):
    """Assign SKUs to a taxonomy node locally; queue channel pushes for large sets."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    params = {
        "node_id": node_id,
        "dry_run": bool(payload.get("dry_run", False)),
        "skus": payload.get("skus"),
        "only_assigned": bool(payload.get("only_assigned", False)),
        "category_l1": payload.get("category_l1"),
        "category_l2": payload.get("category_l2"),
        "category_l3": payload.get("category_l3"),
        "collection": payload.get("collection"),
        "product_family": payload.get("product_family"),
        "search": payload.get("search"),
        "replace_existing": bool(payload.get("replace_existing", False)),
        "limit": payload.get("limit"),
        "enqueue_channel_push": bool(payload.get("enqueue_channel_push", True)),
        "inline_sku_threshold": int(payload.get("inline_sku_threshold", 25)),
        "magento_connection_id": payload.get("magento_connection_id"),
        "shopify_connection_id": payload.get("shopify_connection_id"),
        "include_magento": bool(payload.get("include_magento", True)),
        "include_shopify": bool(payload.get("include_shopify", True)),
    }
    # Always background for live assigns (ignore inline_sku_threshold). Inline used to
    # await Shopify push_products and Cloudflare returned 502 on the taxonomy editor.
    use_background = bool(background) and not params["dry_run"]
    if use_background:
        from app.jobs.master_taxonomy_enqueue import start_master_taxonomy_job

        job_id = start_master_taxonomy_job("sku_assign", params)
        return {"status": "queued", "job_id": job_id, "poll_url": f"/api/master-taxonomy/jobs/{job_id}"}
    from app.jobs.master_taxonomy_enqueue import _execute_sku_assign

    result = _execute_sku_assign(params)
    return {"status": "ok", **result}


@app.get("/api/master-taxonomy/jobs/{job_id}")
async def master_taxonomy_job_status(job_id: str):
    from app.jobs.master_taxonomy_enqueue import get_master_taxonomy_job_status

    row = get_master_taxonomy_job_status(job_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Job not found")
    return row


@app.patch("/api/master-taxonomy/nodes/{node_id}/reparent")
async def master_taxonomy_node_reparent(node_id: int, payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_sync import build_node_channel_sync_params, reparent_taxonomy_node, taxonomy_sync_node_ids

    sync_channels = bool(payload.get("sync_channels", True))
    background_sync = bool(payload.get("background_sync", True))
    try:
        with get_session() as session:
            result = reparent_taxonomy_node(
                session,
                node_id,
                parent_id=payload.get("parent_id"),
                sort_order=payload.get("sort_order"),
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    if not sync_channels:
        return {"status": "ok", **result}

    push_params = build_node_channel_sync_params(
        node_id,
        payload,
        node_ids=taxonomy_sync_node_ids(node_id, result),
        include_magento=bool(payload.get("include_magento", True)),
        include_shopify=bool(payload.get("include_shopify", True)),
    )
    if background_sync:
        from app.jobs.master_taxonomy_enqueue import start_master_taxonomy_job

        job_id = start_master_taxonomy_job("push_nodes", push_params)
        return {
            "status": "ok",
            **result,
            "channel_sync": {"status": "queued", "job_id": job_id, "poll_url": f"/api/master-taxonomy/jobs/{job_id}"},
        }

    from app.jobs.master_taxonomy_enqueue import _execute_push_nodes

    push_result = _execute_push_nodes(push_params)
    return {"status": "ok", **result, "channel_sync": push_result}


@app.get("/api/master-taxonomy/nodes/{node_id}")
async def master_taxonomy_node_get(node_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_hierarchy import list_taxonomy_tree

    with get_session() as session:
        tree = list_taxonomy_tree(session)
        node = next((n for n in tree["nodes"] if n["id"] == node_id), None)
        if node is None:
            raise HTTPException(status_code=404, detail="Taxonomy node not found")
        parent_options = [
            {"id": n["id"], "name": n["name"], "path_slug": n["path_slug"], "node_kind": n["node_kind"]}
            for n in tree["nodes"]
            if n["id"] != node_id and n["node_kind"] in {"hub", "category"}
        ]
    return {"status": "ok", "node": node, "parent_options": parent_options}


@app.delete("/api/master-taxonomy/nodes/{node_id}")
async def master_taxonomy_node_delete(
    node_id: int,
    cascade: bool = Query(False),
    purge_remote: bool = Query(False),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_delete import deactivate_taxonomy_node
    from db.master_taxonomy_remote_purge import resolve_channel_clients_for_purge

    try:
        with get_session() as session:
            magento_api = None
            shopify_client = None
            if purge_remote:
                magento_api, shopify_client = resolve_channel_clients_for_purge(
                    session,
                    magento_connection_id=magento_connection_id,
                    shopify_connection_id=shopify_connection_id,
                )
            result = deactivate_taxonomy_node(
                session,
                node_id,
                cascade=cascade,
                note="deactivated from Taxonomies dashboard",
                purge_remote=purge_remote,
                magento_api=magento_api,
                shopify_client=shopify_client,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "deactivated": True, **result}


@app.get("/api/dashboard/collections/polluted")
async def dashboard_collections_polluted(
    hub_path_slug: str = Query("kitchen-cabinets"),
    include_inactive: bool = Query(True),
    include_orphans: bool = Query(False),
    magento_connection_id: Optional[int] = Query(None),
    shopify_connection_id: Optional[int] = Query(None),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.collection_path_guard import (
        list_orphan_remote_collections,
        list_polluted_collection_paths,
        list_polluted_remote_catalog_targets,
    )

    with get_session() as session:
        polluted = list_polluted_collection_paths(
            session,
            hub_path_slug=hub_path_slug,
            include_inactive=include_inactive,
        )
        remote_targets = list_polluted_remote_catalog_targets(
            session,
            hub_path_slug=hub_path_slug,
            magento_connection_id=magento_connection_id,
            shopify_connection_id=shopify_connection_id,
            include_inactive_nodes=include_inactive,
        )
        orphans = []
        if include_orphans:
            orphans = list_orphan_remote_collections(
                session,
                hub_path_slug=hub_path_slug,
                magento_connection_id=magento_connection_id,
                shopify_connection_id=shopify_connection_id,
            )
    orphan_tier_counts: dict = {}
    for row in orphans:
        tier = row.get("orphan_tier") or "unknown"
        orphan_tier_counts[tier] = orphan_tier_counts.get(tier, 0) + 1
    return {
        "status": "ok",
        "hub_path_slug": hub_path_slug,
        "polluted": polluted,
        "count": len(polluted),
        "remote_targets": remote_targets,
        "remote_target_count": len(remote_targets),
        "orphans": orphans,
        "orphan_count": len(orphans),
        "orphan_tier_counts": orphan_tier_counts,
    }


@app.post("/api/dashboard/collections/prune-polluted")
async def dashboard_collections_prune_polluted(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.prune_polluted_collections import prune_polluted_collections

    dry_run = bool(payload.get("dry_run", True))
    purge_remote = bool(payload.get("purge_remote", False))
    hub_path_slug = str(payload.get("hub_path_slug") or "kitchen-cabinets")
    orphan_tiers = payload.get("orphan_tiers")
    if isinstance(orphan_tiers, str):
        orphan_tiers = [orphan_tiers]
    try:
        with get_session() as session:
            result = prune_polluted_collections(
                session,
                hub_path_slug=hub_path_slug,
                purge_remote=purge_remote,
                dry_run=dry_run,
                magento_connection_id=payload.get("magento_connection_id"),
                shopify_connection_id=payload.get("shopify_connection_id"),
                include_inactive_nodes=bool(payload.get("include_inactive_nodes", True)),
                remote_only=bool(payload.get("remote_only", False)),
                include_orphans=bool(payload.get("include_orphans", False)),
                orphan_tiers=orphan_tiers,
            )
            if not dry_run:
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.get("/api/master-taxonomy/nodes/{node_id}/channel-candidates")
async def master_taxonomy_node_channel_candidates(
    node_id: int,
    channel_code: str = Query(...),
    connection_id: Optional[int] = Query(None),
):
    """Find existing Magento categories / Shopify collections that may match a taxonomy node."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_sync import find_taxonomy_channel_candidates
    from db.models import MasterTaxonomyNode, ShopifyConnection

    channel_code = str(channel_code or "").strip().lower()
    shopify_client = None
    shopify_shop_code = None

    with get_session() as session:
        if channel_code == "shopify":
            native_shopify = (
                _resolve_native_connection_id(session, connection_id)
                if connection_id
                else _default_native_connection_id(session, "shopify")
            )
            if native_shopify:
                shopify_connection = session.get(ShopifyConnection, native_shopify)
                if shopify_connection and shopify_connection.status == "active":
                    from shopify.connections import build_client

                    shopify_client = build_client(shopify_connection)
                    shopify_shop_code = shopify_connection.shop_code
                connection_id = native_shopify
        elif channel_code == "magento":
            connection_id = (
                _resolve_native_connection_id(session, connection_id)
                if connection_id
                else _default_native_connection_id(session, "magento")
            )
        else:
            raise HTTPException(status_code=400, detail="channel_code must be magento or shopify")

        node = session.get(MasterTaxonomyNode, int(node_id))
        if node is None or not node.is_active:
            raise HTTPException(status_code=404, detail="Taxonomy node not found")
        if connection_id is None:
            raise HTTPException(status_code=400, detail=f"No active {channel_code} connection configured")

        result = find_taxonomy_channel_candidates(
            session,
            node,
            channel_code=channel_code,
            connection_id=int(connection_id),
            client=shopify_client,
        )
        result["shop_code"] = shopify_shop_code
    return {"status": "ok", **result}


@app.post("/api/master-taxonomy/nodes/{node_id}/link-channel")
async def master_taxonomy_node_link_channel(node_id: int, payload: dict = Body(...)):
    """Link an existing remote Magento category or Shopify collection to a taxonomy node."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_sync import link_taxonomy_node_channel
    from db.models import ShopifyConnection

    channel_code = str(payload.get("channel_code") or "").strip().lower()
    remote_id = str(payload.get("remote_id") or "").strip()
    if not channel_code or not remote_id:
        raise HTTPException(status_code=400, detail="channel_code and remote_id are required")

    try:
        with get_session() as session:
            connection_id = payload.get("connection_id")
            shop_code = None
            if channel_code == "shopify":
                native_shopify = (
                    _resolve_native_connection_id(session, connection_id)
                    if connection_id
                    else _default_native_connection_id(session, "shopify")
                )
                if native_shopify is None:
                    raise ValueError("No active Shopify connection configured")
                shop_conn = session.get(ShopifyConnection, native_shopify)
                shop_code = shop_conn.shop_code if shop_conn else None
                connection_id = native_shopify
            elif channel_code == "magento":
                connection_id = (
                    _resolve_native_connection_id(session, connection_id)
                    if connection_id
                    else _default_native_connection_id(session, "magento")
                )
                if connection_id is None:
                    raise ValueError("No active Magento connection configured")
            else:
                raise ValueError("channel_code must be magento or shopify")

            result = link_taxonomy_node_channel(
                session,
                node_id,
                channel_code=channel_code,
                remote_id=remote_id,
                connection_id=int(connection_id),
                shop_code=shop_code,
            )
            session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

    return {"status": "ok", **result}


@app.get("/api/master-taxonomy/reconcile/magento")
async def master_taxonomy_reconcile_magento(
    connection_id: Optional[int] = Query(None),
    node_kind: Optional[str] = Query(None),
    limit: int = Query(500, ge=1, le=2000),
):
    """Find Magento category duplicates and link gaps for master taxonomy collection nodes."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_sync import reconcile_magento_taxonomy_duplicates

    with get_session() as session:
        native_id = (
            _resolve_native_connection_id(session, connection_id)
            if connection_id
            else _default_native_connection_id(session, "magento")
        )
        if native_id is None:
            raise HTTPException(status_code=400, detail="No active Magento connection configured")
        result = reconcile_magento_taxonomy_duplicates(
            session,
            connection_id=int(native_id),
            node_kind=node_kind,
            limit=limit,
        )
    return {"status": "ok", **result}


@app.get("/api/master-taxonomy/merge-suggestions")
async def master_taxonomy_merge_suggestions(
    parent_path_slug: Optional[str] = Query(None),
    limit: int = Query(200, ge=1, le=1000),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_merge import suggest_taxonomy_merge_groups

    with get_session() as session:
        suggestions = suggest_taxonomy_merge_groups(
            session,
            parent_path_slug=parent_path_slug,
            limit=limit,
        )
    return {"status": "ok", "suggestions": suggestions, "count": len(suggestions)}


@app.get("/api/master-taxonomy/path-aliases")
async def master_taxonomy_path_aliases(canonical_node_id: Optional[int] = Query(None)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_merge import list_taxonomy_path_aliases

    with get_session() as session:
        rows = list_taxonomy_path_aliases(session, canonical_node_id=canonical_node_id)
    return {"status": "ok", "aliases": rows, "count": len(rows)}


@app.post("/api/master-taxonomy/nodes/merge")
async def master_taxonomy_nodes_merge(payload: dict = Body(...)):
    """Merge alias taxonomy nodes into a canonical node (SKUs, path aliases, channel links)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_merge import merge_taxonomy_nodes

    canonical_node_id = payload.get("canonical_node_id")
    alias_node_ids = payload.get("alias_node_ids") or []
    if canonical_node_id is None:
        raise HTTPException(status_code=400, detail="canonical_node_id is required")
    if not alias_node_ids:
        raise HTTPException(status_code=400, detail="alias_node_ids is required")
    try:
        with get_session() as session:
            result = merge_taxonomy_nodes(
                session,
                canonical_node_id=int(canonical_node_id),
                alias_node_ids=[int(node_id) for node_id in alias_node_ids],
                dry_run=bool(payload.get("dry_run", False)),
                deactivate_alias_nodes=bool(payload.get("deactivate_alias_nodes", True)),
            )
            if not result.get("dry_run"):
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/master-taxonomy/fix-all")
async def master_taxonomy_fix_all(payload: dict = Body({}), background: bool = Query(True)):
    """One-shot taxonomy repair: create nodes, link/create remotes, assign SKUs, queue product pushes."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.master_taxonomy_enqueue import _enforce_manual_taxonomy_fix_all_mode

    taxonomy_mode, requested_taxonomy_mode = _enforce_manual_taxonomy_fix_all_mode(payload.get("taxonomy_mode"))
    params = {
        "dry_run": bool(payload.get("dry_run", False)),
        "magento_connection_id": payload.get("magento_connection_id"),
        "shopify_connection_id": payload.get("shopify_connection_id"),
        "include_magento": bool(payload.get("include_magento", True)),
        "include_shopify": bool(payload.get("include_shopify", True)),
        "pull_remotes": bool(payload.get("pull_remotes", True)),
        "create_missing": bool(payload.get("create_missing", True)),
        "push_products": bool(payload.get("push_products", True)),
        "reprocess_master": bool(payload.get("reprocess_master", False)),
        "skus": payload.get("skus"),
        "taxonomy_mode": taxonomy_mode,
    }
    if requested_taxonomy_mode != taxonomy_mode:
        params["taxonomy_mode_requested"] = requested_taxonomy_mode
    if background and not params["dry_run"]:
        from app.jobs.master_taxonomy_enqueue import start_master_taxonomy_job

        job_id = start_master_taxonomy_job("fix_all", params)
        response = {
            "status": "queued",
            "job_id": job_id,
            "poll_url": f"/api/master-taxonomy/jobs/{job_id}",
        }
        if requested_taxonomy_mode != taxonomy_mode:
            response["taxonomy_mode_requested"] = requested_taxonomy_mode
            response["taxonomy_mode_enforced"] = taxonomy_mode
        return response
    from app.jobs.master_taxonomy_enqueue import _execute_fix_all

    result = _execute_fix_all(params)
    return {"status": "ok", **result}


@app.post("/api/master-taxonomy/push-magento-outbound")
async def master_taxonomy_push_magento_outbound(payload: dict = Body({})):
    """Shortest Magento re-push after master taxonomy is fixed.

    Repairs live category_links to L3 + collection + all-products, then enqueues
    a products-only force upsert (categories + shopping_* attrs).
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.push_magento_taxonomy_outbound import run_push_magento_taxonomy_outbound

    connection_id = payload.get("connection_id") or payload.get("magento_connection_id")
    if not connection_id:
        raise HTTPException(status_code=400, detail="connection_id is required")
    try:
        result = run_push_magento_taxonomy_outbound(
            connection_id=int(connection_id),
            skus=payload.get("skus"),
            path_prefix=payload.get("path_prefix"),
            collection_path_slug=payload.get("collection_path") or payload.get("collection_path_slug"),
            limit=payload.get("limit"),
            apply=not bool(payload.get("dry_run", True)),
            repair_links=bool(payload.get("repair_links", True)),
            wait=bool(payload.get("wait", False)),
            run_worker=bool(payload.get("run_worker", False)),
            wait_timeout=int(payload.get("wait_timeout") or 3600),
        )
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return result


@app.post("/api/master-taxonomy/link-products-from-master")
async def master_taxonomy_link_products_from_master(payload: dict = Body({})):
    """Assign master SKUs to taxonomy listing paths inferred from category_l1/collection fields."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_sync import link_products_to_taxonomy_from_master

    try:
        with get_session() as session:
            magento_id = (
                _resolve_native_connection_id(session, payload.get("magento_connection_id"))
                if payload.get("magento_connection_id")
                else _default_native_connection_id(session, "magento")
            )
            shopify_id = (
                _resolve_native_connection_id(session, payload.get("shopify_connection_id"))
                if payload.get("shopify_connection_id")
                else _default_native_connection_id(session, "shopify")
            )
            result = link_products_to_taxonomy_from_master(
                session,
                skus=payload.get("skus"),
                only_unlinked=bool(payload.get("only_unlinked", False)),
                replace_existing=bool(payload.get("replace_existing", False)),
                sync_channels=bool(payload.get("sync_channels", False)),
                magento_connection_id=int(magento_id) if magento_id else None,
                shopify_connection_id=int(shopify_id) if shopify_id else None,
                dry_run=bool(payload.get("dry_run", True)),
                limit=payload.get("limit"),
            )
            if not result.get("dry_run"):
                session.commit()
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/master-taxonomy/proposals/apply")
async def master_taxonomy_proposals_apply(payload: dict = Body(...), background: bool = Query(False)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    proposals = payload.get("proposals") or []
    if not proposals:
        raise HTTPException(status_code=400, detail="proposals is required")

    params = {
        "proposals": proposals,
        "dry_run": bool(payload.get("dry_run", False)),
        "sync_channels": bool(payload.get("sync_channels", False)),
        "magento_connection_id": payload.get("magento_connection_id"),
        "shopify_connection_id": payload.get("shopify_connection_id"),
        "include_magento": bool(payload.get("include_magento", True)),
        "include_shopify": bool(payload.get("include_shopify", True)),
        "create_missing": bool(payload.get("create_missing", True)),
        "allow_taxonomy_invent": bool(payload.get("allow_taxonomy_invent", False)),
    }
    if background and not params["dry_run"]:
        from app.jobs.master_taxonomy_enqueue import start_master_taxonomy_job

        job_id = start_master_taxonomy_job("apply_proposals", params)
        return {"status": "queued", "job_id": job_id, "poll_url": f"/api/master-taxonomy/jobs/{job_id}"}

    from db.master_taxonomy_intent import apply_taxonomy_proposals

    with get_session() as session:
        native_magento = (
            _resolve_native_connection_id(session, params["magento_connection_id"])
            if params.get("magento_connection_id")
            else _default_native_connection_id(session, "magento")
        )
        native_shopify = (
            _resolve_native_connection_id(session, params["shopify_connection_id"])
            if params.get("shopify_connection_id")
            else _default_native_connection_id(session, "shopify")
        )
        magento_api = shopify_client = shopify_shop_code = None
        if params["sync_channels"] and not params["dry_run"]:
            from app.jobs.master_taxonomy_enqueue import _resolve_connections

            native_magento, native_shopify, magento_api, shopify_client, shopify_shop_code = _resolve_connections(
                params
            )
        result = apply_taxonomy_proposals(
            session,
            proposals,
            dry_run=params["dry_run"],
            sync_channels=params["sync_channels"],
            magento_connection_id=native_magento,
            shopify_connection_id=native_shopify,
            magento_api=magento_api,
            shopify_client=shopify_client,
            shopify_shop_code=shopify_shop_code,
            allow_invent=bool(params.get("allow_taxonomy_invent", False)),
        )
        if not params["dry_run"] and bool(params.get("allow_taxonomy_invent", False)):
            from db.master_taxonomy_hierarchy import build_hierarchy_from_master_catalog

            build_hierarchy_from_master_catalog(session, dry_run=False, allow_invent=True)
            session.commit()
        elif not params["dry_run"]:
            session.commit()
    return {"status": "ok", **result}


@app.get("/api/master-taxonomy/brands")
async def master_taxonomy_brands(search: Optional[str] = Query(None), limit: int = Query(500, ge=1, le=2000)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import list_brands

    with get_session() as session:
        rows = list_brands(session, search=search, limit=limit)
    return {"status": "ok", "count": len(rows), "brands": rows}


@app.post("/api/master-taxonomy/brands")
async def master_taxonomy_brands_upsert(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import upsert_brand

    with get_session() as session:
        row = upsert_brand(session, payload)
        session.commit()
    return {"status": "ok", "brand": row}


@app.delete("/api/master-taxonomy/brands/{brand_id}")
async def master_taxonomy_brands_delete(brand_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import deactivate_brand

    with get_session() as session:
        ok = deactivate_brand(session, brand_id)
        if not ok:
            raise HTTPException(status_code=404, detail="Brand not found")
        session.commit()
    return {"status": "ok", "deactivated": True}


@app.get("/api/master-taxonomy/families")
async def master_taxonomy_families(search: Optional[str] = Query(None), limit: int = Query(500, ge=1, le=2000)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import list_families

    with get_session() as session:
        rows = list_families(session, search=search, limit=limit)
    return {"status": "ok", "count": len(rows), "families": rows}


@app.post("/api/master-taxonomy/families")
async def master_taxonomy_families_upsert(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import upsert_family

    with get_session() as session:
        row = upsert_family(session, payload)
        session.commit()
    return {"status": "ok", "family": row}


@app.delete("/api/master-taxonomy/families/{family_id}")
async def master_taxonomy_families_delete(family_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import deactivate_family

    with get_session() as session:
        ok = deactivate_family(session, family_id)
        if not ok:
            raise HTTPException(status_code=404, detail="Family not found")
        session.commit()
    return {"status": "ok", "deactivated": True}


@app.get("/api/master-taxonomy/product-types")
async def master_taxonomy_product_types(
    family_id: Optional[int] = Query(None),
    search: Optional[str] = Query(None),
    limit: int = Query(500, ge=1, le=2000),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import list_product_types

    with get_session() as session:
        rows = list_product_types(session, family_id=family_id, search=search, limit=limit)
    return {"status": "ok", "count": len(rows), "product_types": rows}


@app.post("/api/master-taxonomy/product-types")
async def master_taxonomy_product_types_upsert(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import upsert_product_type

    with get_session() as session:
        row = upsert_product_type(session, payload)
        session.commit()
    return {"status": "ok", "product_type": row}


@app.delete("/api/master-taxonomy/product-types/{product_type_id}")
async def master_taxonomy_product_types_delete(product_type_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import deactivate_product_type

    with get_session() as session:
        ok = deactivate_product_type(session, product_type_id)
        if not ok:
            raise HTTPException(status_code=404, detail="Product type not found")
        session.commit()
    return {"status": "ok", "deactivated": True}


@app.get("/api/master-taxonomy/collections")
async def master_taxonomy_collections(
    family_id: Optional[int] = Query(None),
    brand_id: Optional[int] = Query(None),
    search: Optional[str] = Query(None),
    limit: int = Query(500, ge=1, le=2000),
):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import list_collections

    with get_session() as session:
        rows = list_collections(session, family_id=family_id, brand_id=brand_id, search=search, limit=limit)
    return {"status": "ok", "count": len(rows), "collections": rows}


@app.post("/api/master-taxonomy/collections")
async def master_taxonomy_collections_upsert(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import upsert_collection_registry

    with get_session() as session:
        row = upsert_collection_registry(session, payload, allow_invent=True)
        session.commit()
    return {"status": "ok", "collection": row}


@app.delete("/api/master-taxonomy/collections/{collection_id}")
async def master_taxonomy_collections_delete(collection_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import deactivate_collection_registry

    with get_session() as session:
        ok = deactivate_collection_registry(session, collection_id)
        if not ok:
            raise HTTPException(status_code=404, detail="Collection registry not found")
        session.commit()
    return {"status": "ok", "deactivated": True}


@app.get("/api/master-taxonomy/collections/{collection_id}/links")
async def master_taxonomy_collection_links(collection_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_taxonomy_registry import get_collection_registry_links

    with get_session() as session:
        try:
            links = get_collection_registry_links(session, registry_id=collection_id)
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"status": "ok", **links}


@app.get("/api/manual-taxonomy-assignments")
async def manual_taxonomy_assignments_get():
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.manual_taxonomy_assignments import list_manual_taxonomy_assignments

    with get_session() as session:
        payload = list_manual_taxonomy_assignments(session)
    return {"status": "ok", **payload}


@app.put("/api/manual-taxonomy-assignments")
async def manual_taxonomy_assignments_put(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from sqlalchemy import select as sa_select

    from db.channel_projection_invalidation import invalidate_skus
    from db.manual_taxonomy_assignments import replace_manual_taxonomy_assignments
    from db.models import ChannelProjectionRow

    with get_session() as session:
        try:
            result = replace_manual_taxonomy_assignments(session, payload)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        affected_skus = sorted(
            {
                str(sku).strip().upper()
                for sku in (result.get("changed_master_skus") or [])
                if str(sku).strip()
            }
        )
        invalidations = []
        if affected_skus:
            projection_targets = session.execute(
                sa_select(
                    ChannelProjectionRow.channel_code,
                    ChannelProjectionRow.connection_id,
                )
                .where(ChannelProjectionRow.master_sku.in_(affected_skus))
                .distinct()
                .order_by(ChannelProjectionRow.channel_code, ChannelProjectionRow.connection_id)
            ).all()
            for channel_code, connection_id in projection_targets:
                invalidations.append(
                    invalidate_skus(
                        session,
                        channel_code=str(channel_code),
                        connection_id=connection_id,
                        skus=affected_skus,
                        reason="manual_taxonomy_assignment_updated",
                    )
                )
        result["projection_invalidations"] = invalidations
        session.commit()
    return {"status": "ok", **result}


@app.get("/api/manual-attribute-locks")
async def manual_attribute_locks_get():
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.manual_attribute_locks import list_manual_attribute_locks

    with get_session() as session:
        payload = list_manual_attribute_locks(session)
    return {"status": "ok", **payload}


@app.put("/api/manual-attribute-locks")
async def manual_attribute_locks_put(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.manual_attribute_locks import replace_manual_attribute_locks

    with get_session() as session:
        try:
            result = replace_manual_attribute_locks(session, payload)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
    return {"status": "ok", **result}


@app.get("/api/manual-attributes")
async def manual_attributes_get():
    """Locked attribute vocabularies (allowed values) for the manual-attributes dashboard."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.manual_attributes import ensure_default_manual_attributes, list_manual_attributes

    with get_session() as session:
        ensure_default_manual_attributes(session)
        payload = list_manual_attributes(session)
        session.commit()
    return {"status": "ok", **payload}


@app.put("/api/manual-attributes")
async def manual_attributes_put(payload: dict = Body(...)):
    """Replace/upsert locked attribute vocabularies (pollution guard)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.manual_attributes import replace_manual_attributes

    with get_session() as session:
        try:
            result = replace_manual_attributes(session, payload)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
    return {"status": "ok", **result}


@app.get("/api/master-catalog/intent-runs/{run_id}")
async def master_catalog_intent_run_get(run_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_catalog_intent import get_intent_run

    with get_session() as session:
        try:
            row = get_intent_run(session, run_id)
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"status": "ok", "intent_run": row}


@app.post("/api/master-catalog/intent-runs/{run_id}/confirm")
async def master_catalog_intent_run_confirm(run_id: int, payload: dict = Body(default_factory=dict)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.master_catalog_intent import confirm_intent_run

    with get_session() as session:
        try:
            result = confirm_intent_run(
                session,
                run_id,
                create_registry_rows=bool(payload.get("create_registry_rows", False)),
                link_products=bool(payload.get("link_products", True)),
                run_backfill=bool(payload.get("run_backfill", False)),
                build_hierarchy=bool(payload.get("build_hierarchy", False)),
                approved_taxonomy_proposals=payload.get("approved_taxonomy_proposals"),
                sync_taxonomy_channels=bool(payload.get("sync_taxonomy_channels", False)),
                magento_connection_id=payload.get("magento_connection_id"),
                shopify_connection_id=payload.get("shopify_connection_id"),
                dry_run=bool(payload.get("dry_run", False)),
                allow_taxonomy_invent=bool(payload.get("allow_taxonomy_invent", False)),
            )
            if not payload.get("dry_run"):
                session.commit()
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/channel-taxonomy-mappings/upsert")
async def channel_taxonomy_mappings_upsert(payload: dict = Body(...)):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_taxonomy import upsert_taxonomy_mapping

    try:
        with get_session() as session:
            row = upsert_taxonomy_mapping(session, **payload)
            session.commit()
    except (TypeError, ValueError) as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    return {"status": "ok", "mapping": row}


@app.delete("/api/channel-taxonomy-mappings/{mapping_id}")
async def channel_taxonomy_mappings_delete(mapping_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.channel_taxonomy import deactivate_taxonomy_mapping

    with get_session() as session:
        ok = deactivate_taxonomy_mapping(session, mapping_id)
        if not ok:
            raise HTTPException(status_code=404, detail="Mapping not found")
        session.commit()
    return {"status": "ok", "deactivated": True}


@app.delete("/api/product-channel-taxonomy/assignments/{assignment_id}")
async def product_channel_taxonomy_assignment_delete(assignment_id: int):
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.product_channel_taxonomy import deactivate_sku_taxonomy_assignment

    with get_session() as session:
        try:
            result = deactivate_sku_taxonomy_assignment(session, assignment_id=assignment_id)
            session.commit()
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
    return {"status": "ok", **result}


@app.post("/api/master-product-images/import-csv")
async def import_master_product_images_csv(
    file: UploadFile = File(...),
    source_label: str = Query("images_csv"),
):
    """Import a Name+URL image sheet and associate rows to master SKUs by filename."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    with get_session() as session:
        from db.master_product_images import import_master_product_images_dataframe

        stats = import_master_product_images_dataframe(session, df, source_label=source_label)
        session.commit()
    return {"status": "ok", "source": "master_product_images", "source_label": source_label, **stats}


@app.post("/api/master-product-images/import-photo-assets-csv")
async def import_master_product_photo_assets_csv(
    file: UploadFile = File(...),
    source_label: str = Query("photo_assets_csv"),
    fill_family_vignettes: bool = Query(
        True,
        description="Copy collection vignettes to style siblings (and catalog peers) that lack them.",
    ),
):
    """Import a wide Photo_Assets_By_SKU CSV (SKU + Image_* / Collection_* columns)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not file.filename or not file.filename.lower().endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files are supported")
    content = await file.read()
    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")
    with get_session() as session:
        from db.master_product_images import import_photo_assets_dataframe

        try:
            stats = import_photo_assets_dataframe(
                session,
                df,
                source_label=source_label,
                fill_family_vignettes=fill_family_vignettes,
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        session.commit()
    return {
        "status": "ok",
        "source": "master_product_images",
        "source_label": source_label,
        **stats,
    }


@app.get("/api/master-product-images/summary")
async def master_product_images_summary():
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        from db.master_product_images import master_image_summary

        summary = master_image_summary(session)
    return {"status": "ok", **summary}


@app.post("/api/master-skus/reprocess")
async def reprocess_master_skus(
    source_label: str = Query("master_sku_reprocess"),
    batch_size: int = Query(500, ge=1, le=2000),
    start_offset: int = Query(0, ge=0),
    max_batches: int | None = Query(None, ge=1, le=500),
):
    """Rebuild normalized Master SKU tables from stored raw payloads."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.reprocess_master_skus import run_reprocess_master_skus

    return run_reprocess_master_skus(
        source_label=source_label,
        batch_size=batch_size,
        start_offset=start_offset,
        max_batches=max_batches,
    )


@app.get("/api/master-skus/validation")
async def master_skus_validation():
    """Return master catalog health checks after importing Master SKU data."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        report = build_master_catalog_validation(session)
    return {"status": "ok", **report}


@app.get("/api/master-skus/validation.csv")
async def master_skus_validation_csv():
    """Download master catalog health checks as CSV."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        report = build_master_catalog_validation(session)
    df = pd.DataFrame(flatten_master_catalog_validation(report))
    buf = io.StringIO()
    df.to_csv(buf, index=False)
    return StreamingResponse(
        io.BytesIO(buf.getvalue().encode("utf-8")),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=master_sku_validation.csv"},
    )


def _split_normalized_df_by_families(
    df: pd.DataFrame,
    seed_children_col: str,
    max_per_file: int = 50,
) -> List[pd.DataFrame]:
    """Split normalized dataframe into chunks of at most max_per_file rows, keeping each
    parent (configurable) and its children in the same chunk.
    """
    if df.empty:
        return []
    sku_col = _case_insensitive_col(df, "sku") or "sku"
    seed_col = _case_insensitive_col(df, seed_children_col)
    if sku_col not in df.columns:
        return [df] if len(df) <= max_per_file else [df.iloc[i : i + max_per_file] for i in range(0, len(df), max_per_file)]

    all_skus = {s for s in df[sku_col].astype(str).str.strip() if s}
    parent_to_children: Dict[str, set[str]] = {}
    if seed_col and seed_col in df.columns:
        for _, row in df.iterrows():
            parent_sku = str(row.get(sku_col, "")).strip()
            if not parent_sku:
                continue
            seed_val = row.get(seed_col, "")
            if _normalize_is_empty(seed_val):
                continue
            children = set(_extract_child_skus(seed_val))
            if parent_sku not in parent_to_children:
                parent_to_children[parent_sku] = set()
            parent_to_children[parent_sku].update(children)

    family_sets: List[set[str]] = []
    seen_skus: set[str] = set()
    for parent_sku, children in parent_to_children.items():
        family = {parent_sku} | {c for c in children if c}
        if family:
            family_sets.append(family)
            seen_skus |= family
    for sku in all_skus - seen_skus:
        family_sets.append({sku})

    chunks_skus: List[set[str]] = []
    current: set[str] = set()
    for family in family_sets:
        if current and len(current) + len(family) > max_per_file:
            chunks_skus.append(current)
            current = set()
        current |= family
    if current:
        chunks_skus.append(current)

    result: List[pd.DataFrame] = []
    for chunk_skus in chunks_skus:
        mask = df[sku_col].astype(str).str.strip().isin(chunk_skus)
        result.append(df.loc[mask].copy())
    return result


@app.post("/api/plytix/feed/trigger-sync")
async def plytix_feed_trigger_sync(
    label: str = Body(..., embed=True),
    connection_ids: Optional[List[int]] = Body(None, embed=True),
    dry_run: bool = Body(False, embed=True),
    notify_email: Optional[str] = Body(None, embed=True),
    force_relations: bool = Body(False, embed=True),
    replace_options: bool = Body(False, embed=True),
    force_full_sync: bool = Body(False, embed=True),
    debug_output_path: Optional[str] = Body(None, embed=True),
    trace_skus: Optional[List[str]] = Body(None, embed=True),
):
    """When Plytix pipeline finishes normalization, queue Magento sync for connection(s).
    force_full_sync=True: force UPSERT_PRODUCT and SET_RELATIONS for all products (fix disabled, MSI, qty 0)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not label or not str(label).strip():
        raise HTTPException(status_code=400, detail="label required")
    with get_session() as session:
        feed_repo = SqlAlchemyPlytixFeedEventRepository(session)
        queue_repo = SqlAlchemyMagentoSyncQueueRepository(session)
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        ids = connection_ids
        if not ids:
            conns = conn_repo.list_connections()
            ids = [c.id for c in conns]
        if not ids:
            return {"status": "ok", "message": "No connections to queue", "queued": 0}
        feed_repo.create(label, "normalized_ready", {"connection_ids": ids})
        options = {
            "dry_run": dry_run,
            "force_relations": force_relations,
            "replace_options": replace_options,
            "force_full_sync": force_full_sync,
        }
        if debug_output_path:
            options["debug_output_path"] = debug_output_path
        if trace_skus:
            options["trace_skus"] = trace_skus
        if notify_email:
            options["notify_email"] = notify_email
        for cid in ids:
            queue_repo.enqueue(cid, label, options=options)
        session.commit()
    return {"status": "ok", "label": label, "queued": len(ids)}


@app.get("/api/plytix/transform/runtime/{label}")
async def transform_runtime(label: str, nooverwrite: int = 0):
    if not _is_safe_filename(label):
        raise HTTPException(status_code=400, detail="Invalid label")
    upload_dir = _get_upload_dir_for_label(label)
    if not upload_dir:
        raise HTTPException(status_code=404, detail="Upload label not found")

    latest_csv = _get_latest_csv_in_dir(upload_dir)
    if not latest_csv:
        raise HTTPException(status_code=404, detail="No upload CSV files found")

    with open(latest_csv, "rb") as handle:
        content = handle.read()

    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")

    df = canonicalize_plytix_columns(df)
    df = _apply_overrides_if_enabled(df, nooverwrite=bool(nooverwrite))
    cfg = load_magento_config()
    registry_codes = load_attribute_registry_codes(load_db_config().attribute_registry_path)
    registry_options = load_attribute_registry_options(load_db_config().attribute_registry_path)
    allowed_codes = registry_codes or None

    normalized_df, quarantine_df = normalize_plytix_df(
        df,
        cfg,
        allowed_attribute_codes=allowed_codes,
        allowed_attribute_options=registry_options or None,
        enforce_required_name=bool(nooverwrite),
    )
    normalized_df = _shorten_image_urls_if_enabled(normalized_df)

    magento_buf = io.StringIO()
    quarantine_buf = io.StringIO()
    normalized_df.to_csv(magento_buf, index=False)
    quarantine_df.to_csv(quarantine_buf, index=False)

    zip_buf = io.BytesIO()
    with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("magento_products.csv", magento_buf.getvalue())
        z.writestr("quarantine.csv", quarantine_buf.getvalue())

    zip_buf.seek(0)
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
    return StreamingResponse(
        zip_buf,
        media_type="application/zip",
        headers={"Content-Disposition": f"attachment; filename=magento_runtime_{timestamp}.zip"},
    )


@app.get("/api/plytix/transform/split/runtime/{label}")
async def transform_split_runtime(label: str, nooverwrite: int = 0):
    """Transform runtime and return a zip of split CSVs: max 50 records per file,
    with each parent (configurable) and its children kept in the same file.
    """
    if not _is_safe_filename(label):
        raise HTTPException(status_code=400, detail="Invalid label")
    upload_dir = _get_upload_dir_for_label(label)
    if not upload_dir:
        raise HTTPException(status_code=404, detail="Upload label not found")

    latest_csv = _get_latest_csv_in_dir(upload_dir)
    if not latest_csv:
        raise HTTPException(status_code=404, detail="No upload CSV files found")

    with open(latest_csv, "rb") as handle:
        content = handle.read()

    try:
        df = pd.read_csv(io.BytesIO(content), dtype=str, keep_default_na=False)
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Failed to read CSV: {exc}")

    df = canonicalize_plytix_columns(df)
    df = _apply_overrides_if_enabled(df, nooverwrite=bool(nooverwrite))
    cfg = load_magento_config()
    registry_codes = load_attribute_registry_codes(load_db_config().attribute_registry_path)
    registry_options = load_attribute_registry_options(load_db_config().attribute_registry_path)
    allowed_codes = registry_codes or None

    normalized_df, quarantine_df = normalize_plytix_df(
        df,
        cfg,
        allowed_attribute_codes=allowed_codes,
        allowed_attribute_options=registry_options or None,
        enforce_required_name=bool(nooverwrite),
    )
    normalized_df = _shorten_image_urls_if_enabled(normalized_df)

    chunks = _split_normalized_df_by_families(
        normalized_df,
        cfg.seed_children_col,
        max_per_file=50,
    )

    zip_buf = io.BytesIO()
    with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as z:
        for i, chunk_df in enumerate(chunks, start=1):
            buf = io.StringIO()
            chunk_df.to_csv(buf, index=False)
            z.writestr(f"part_{i:03d}.csv", buf.getvalue())
        quarantine_buf = io.StringIO()
        quarantine_df.to_csv(quarantine_buf, index=False)
        z.writestr("quarantine.csv", quarantine_buf.getvalue())

    zip_buf.seek(0)
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
    return StreamingResponse(
        zip_buf,
        media_type="application/zip",
        headers={"Content-Disposition": f"attachment; filename=magento_runtime_split_{timestamp}.zip"},
    )


@app.get("/api/plytix/transform/download/{label}")
@app.get("/api/plytix/transform/download/{label}/{date_file}")
async def download_transformed_csv(label: str, date_file: Optional[str] = None):
    if not _is_safe_filename(label):
        raise HTTPException(status_code=400, detail="Invalid label")
    download_dir = os.path.join(PLYTIX_DOWNLOAD_DIR, label)
    if not os.path.isdir(download_dir):
        raise HTTPException(status_code=404, detail="No transformed files for this label")

    if date_file:
        if not _is_safe_filename(date_file) or not date_file.lower().endswith(".csv"):
            raise HTTPException(status_code=400, detail="Invalid date filename")
        selected_path = os.path.join(download_dir, date_file)
        if not os.path.isfile(selected_path):
            raise HTTPException(status_code=404, detail="File not found")
    else:
        csv_files = [
            os.path.join(download_dir, name)
            for name in os.listdir(download_dir)
            if name.lower().endswith(".csv")
        ]
        if not csv_files:
            raise HTTPException(status_code=404, detail="No transformed CSV files found")
        selected_path = max(csv_files, key=os.path.getmtime)

    file_name = os.path.basename(selected_path)
    return StreamingResponse(
        open(selected_path, "rb"),
        media_type="text/csv",
        headers={"Content-Disposition": f"attachment; filename={file_name}"},
    )

@app.post("/api/image-url/shorten")
async def shorten_image_url_endpoint(body: dict):
    """Accept {"url": "<long image url>"} and return a short URL using BASE_IMAGE_URL as the base."""
    destination_url: Optional[str] = body.get("url")
    if not destination_url or not destination_url.strip():
        raise HTTPException(status_code=400, detail="'url' field is required")
    destination_url = destination_url.strip()

    if not BASE_IMAGE_URL:
        raise HTTPException(status_code=500, detail="BASE_IMAGE_URL is not configured")

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    with get_session() as session:
        repo = SqlAlchemyIngestRepository(session)
        record = shorten_image_url(repo, destination_url)

    short_url = f"{BASE_IMAGE_URL}/{record.slug}"
    return {"slug": record.slug, "short_url": short_url, "destination_url": record.destination_url}


# ---------- Magento Integration ----------


@app.post("/api/magento/connections")
async def magento_create_connection(payload: MagentoConnectionUpsert):
    """Create or update a Magento connection (store OAuth credentials)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        repo = SqlAlchemyMagentoConnectionRepository(session)
        conn_id = repo.upsert_connection(payload)
    return {"id": conn_id, "status": "created"}


@app.patch("/api/magento/connections/{connection_id}")
async def magento_update_connection(connection_id: int, payload: dict):
    """Partial update of connection (e.g. signature_method for Magento 2.4.4+)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.models import MagentoConnection
    allowed = {
        "signature_method",
        "rest_base_path",
        "magento_api_base_url",
        "internal_api_base_url",
        "internal_host_header",
        "prefer_internal_api",
        "store_base_url",
        "store_code",
        "environment",
        "status",
        "consumer_key",
        "consumer_secret",
        "access_token",
        "access_token_secret",
    }
    updates = {k: v for k, v in payload.items() if k in allowed and v is not None}
    if not updates:
        raise HTTPException(status_code=400, detail="No valid fields to update")
    with get_session() as session:
        row = session.get(MagentoConnection, connection_id)
        if not row:
            raise HTTPException(status_code=404, detail="Connection not found")
        for k, v in updates.items():
            if k in {"store_base_url", "magento_api_base_url", "internal_api_base_url"} and v:
                v = str(v).strip().rstrip("/")
            elif k == "internal_host_header":
                v = str(v).strip() or None
            elif k == "prefer_internal_api":
                v = bool(v)
            setattr(row, k, v)
    return {"updated": list(updates.keys())}


@app.get("/api/magento/connections")
async def magento_list_connections(tenant_id: Optional[str] = None):
    """List Magento connections."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        repo = SqlAlchemyMagentoConnectionRepository(session)
        conns = repo.list_connections(tenant_id=tenant_id)
    return {"connections": [c.model_dump() for c in conns]}


@app.post("/api/magento/connections/{connection_id}/verify")
async def magento_verify_connection(connection_id: int):
    """Verify Magento connection (lightweight API call)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        repo = SqlAlchemyMagentoConnectionRepository(session)
        conn = repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")
        oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
        api = MagentoRestClient(oauth)
        details = api.verify_connection_with_details()
        ok = details["ok"]
        repo.update_verification(
            connection_id,
            success=ok,
            error=None if ok else details.get("error") or f"HTTP {details.get('http_status', 0)}",
        )
    return {
        "verified": ok,
        "http_status": details.get("http_status"),
        "error": None if ok else (details.get("error") or f"HTTP {details.get('http_status')}"),
    }


@app.post("/api/magento/connections/{connection_id}/clone")
async def magento_clone_connection(connection_id: int, payload: dict = Body(...)):
    """Clone Magento connection settings/secrets to a new target store URL."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.models import MagentoConnection
    from db.schemas import MagentoConnectionUpsert

    target_store_base_url = str(
        payload.get("store_base_url") or payload.get("target_store_base_url") or ""
    ).strip().rstrip("/")
    if not target_store_base_url:
        raise HTTPException(status_code=400, detail="store_base_url is required for the cloned connection")

    with get_session() as session:
        source = session.get(MagentoConnection, connection_id)
        if source is None:
            raise HTTPException(status_code=404, detail="Connection not found")
        repo = SqlAlchemyMagentoConnectionRepository(session)
        conn_id = repo.upsert_connection(
            MagentoConnectionUpsert(
                tenant_id=payload.get("tenant_id", source.tenant_id),
                store_base_url=target_store_base_url,
                magento_api_base_url=(
                    str(payload.get("magento_api_base_url")).strip().rstrip("/")
                    if payload.get("magento_api_base_url")
                    else f"{target_store_base_url}/rest"
                ),
                internal_api_base_url=(
                    str(payload.get("internal_api_base_url")).strip().rstrip("/")
                    if payload.get("internal_api_base_url")
                    else source.internal_api_base_url
                ),
                internal_host_header=payload.get("internal_host_header", source.internal_host_header),
                prefer_internal_api=bool(payload.get("prefer_internal_api", source.prefer_internal_api)),
                rest_base_path=payload.get("rest_base_path", source.rest_base_path or "V1"),
                store_code=payload.get("store_code", source.store_code),
                environment=payload.get("environment", source.environment or "production"),
                consumer_key=payload.get("consumer_key", source.consumer_key or ""),
                consumer_secret=payload.get("consumer_secret", source.consumer_secret or ""),
                access_token=payload.get("access_token", source.access_token or ""),
                access_token_secret=payload.get("access_token_secret", source.access_token_secret or ""),
                scopes=payload.get("scopes", source.scopes),
                status=payload.get("status", source.status or "active"),
                magento_version=payload.get("magento_version", source.magento_version),
                is_msi_enabled=payload.get("is_msi_enabled", source.is_msi_enabled),
                default_website_id=payload.get("default_website_id", source.default_website_id),
                default_store_id=payload.get("default_store_id", source.default_store_id),
                protected_fields=payload.get("protected_fields", source.protected_fields),
                signature_method=payload.get("signature_method", source.signature_method or "HMAC-SHA256"),
            )
        )
        session.commit()
    return {
        "status": "created",
        "source_id": connection_id,
        "id": conn_id,
        "compat_id": _compat_id("magento", conn_id),
    }


def _get_magento_api(connection_id: int):
    """Get MagentoRestClient and connection dict. Raises 404 if not found."""
    with get_session() as session:
        repo = SqlAlchemyMagentoConnectionRepository(session)
        conn = repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")
        oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
        api = MagentoRestClient(oauth)
    return api, conn


@app.get("/api/magento/connections/{connection_id}/products")
async def magento_list_products(
    connection_id: int,
    page_size: int = 20,
    current_page: int = 1,
):
    """List products from Magento store (paginated)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    api, _ = _get_magento_api(connection_id)
    status, items, total, err = api.list_products(page_size=page_size, current_page=current_page)
    if status != 200:
        raise HTTPException(status_code=status, detail=err or "Magento API error")
    return {
        "items": items or [],
        "total_count": total,
        "page_size": page_size,
        "current_page": current_page,
    }


@app.get("/api/magento/connections/{connection_id}/products/{sku}")
async def magento_get_product(connection_id: int, sku: str):
    """Get a single product from Magento by SKU."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    api, _ = _get_magento_api(connection_id)
    status, items, err = api.get_products_by_skus([sku])
    if status != 200:
        raise HTTPException(status_code=status, detail=err or "Magento API error")
    item = (items or [None])[0] if items else None
    return {"item": item}


@app.post("/api/magento/connections/{connection_id}/products/by-sku")
async def magento_get_products_by_skus(connection_id: int, body: dict = Body(...)):
    """Get multiple products from Magento by SKU list. Body: { "skus": ["SKU1", "SKU2"] }."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    skus = body.get("skus")
    if not isinstance(skus, list):
        raise HTTPException(status_code=400, detail="Body must include skus array")
    sku_list = [str(s).strip() for s in skus if str(s).strip()]
    if not sku_list:
        raise HTTPException(status_code=400, detail="skus cannot be empty")
    api, _ = _get_magento_api(connection_id)
    status, items, err = api.get_products_by_skus(sku_list)
    if status != 200:
        raise HTTPException(status_code=status, detail=err or "Magento API error")
    return {"items": items or []}


@app.get("/api/magento/connections/{connection_id}/products/compare/{sku}")
async def magento_compare_product(connection_id: int, sku: str):
    """Compare single product from Magento vs Pie DB. Returns { "field:Magento": "x", "field:Pie": "y" }."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    sku_list = [sku.strip()]

    api, _ = _get_magento_api(connection_id)
    status, magento_items, err = api.get_products_by_skus(sku_list)
    if status != 200:
        raise HTTPException(status_code=status, detail=err or "Magento API error")

    with get_session() as session:
        ingest_repo = SqlAlchemyIngestRepository(session)
        snapshots = ingest_repo.load_current_snapshots(sku_list)
        prices = ingest_repo.load_current_prices_for_skus(sku_list)
        overrides = ingest_repo.load_overrides_for_skus(sku_list)

    magento_by_sku = {(p or {}).get("sku"): (p or {}) for p in (magento_items or []) if p and p.get("sku")}
    pie_by_sku = {}
    for s in snapshots:
        sk = str(s.get("sku", "")).strip()
        if sk:
            pie_by_sku[sk] = s

    s = sku_list[0]
    mag_flat = flatten_magento_product(magento_by_sku.get(s, {}))
    pie_snap = pie_by_sku.get(s, {})
    pie_flat = flatten_pie_product(pie_snap, prices, overrides)
    comp = build_comparison(mag_flat, pie_flat)
    return {"sku": s, "comparison": comp}


@app.post("/api/magento/connections/{connection_id}/products/compare")
async def magento_compare_products(connection_id: int, body: dict = Body(...)):
    """Compare multiple products. Body: { "skus": ["SKU1", "SKU2"] }. Returns {sku: { "field:Magento": "x", "field:Pie": "y" }}."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    skus = body.get("skus")
    if not isinstance(skus, list):
        raise HTTPException(status_code=400, detail="Body must include skus array")
    sku_list = [str(s).strip() for s in skus if str(s).strip()]
    if not sku_list:
        raise HTTPException(status_code=400, detail="skus cannot be empty")

    api, _ = _get_magento_api(connection_id)
    status, magento_items, err = api.get_products_by_skus(sku_list)
    if status != 200:
        raise HTTPException(status_code=status, detail=err or "Magento API error")

    with get_session() as session:
        ingest_repo = SqlAlchemyIngestRepository(session)
        snapshots = ingest_repo.load_current_snapshots(sku_list)
        prices = ingest_repo.load_current_prices_for_skus(sku_list)
        overrides = ingest_repo.load_overrides_for_skus(sku_list)

    magento_by_sku = {(p or {}).get("sku"): (p or {}) for p in (magento_items or []) if p and p.get("sku")}
    pie_by_sku = {}
    for s in snapshots:
        sk = str(s.get("sku", "")).strip()
        if sk:
            pie_by_sku[sk] = s

    result = {}
    for s in sku_list:
        mag_flat = flatten_magento_product(magento_by_sku.get(s, {}))
        pie_snap = pie_by_sku.get(s, {})
        pie_flat = flatten_pie_product(pie_snap, prices, overrides)
        comp = build_comparison(mag_flat, pie_flat)
        result[s] = comp

    return {"comparison": result}


@app.post("/api/magento/sync")
async def magento_sync(
    connection_id: int = Body(..., embed=True),
    dry_run: bool = Body(False, embed=True),
    skus: Optional[List[str]] = Body(None, embed=True),
):
    """
    Sync products to Magento. If skus is omitted, syncs all current products.
    Uses same incremental flow as POST /api/magento/sync/incremental: child expansion,
    image URL resolution, store/website injection, category_ids via extension_attributes.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    cfg = load_magento_config()
    registry_codes = load_attribute_registry_codes(load_db_config().attribute_registry_path)
    registry_options = load_attribute_registry_options(load_db_config().attribute_registry_path)

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        sync_repo = SqlAlchemyMagentoSyncRepository(session)
        state_repo = SqlAlchemyMagentoSyncStateRepository(session)
        media_repo = SqlAlchemyMagentoMediaMapRepository(session)
        override_repo = SqlAlchemyMagentoProductOverrideRepository(session)
        version_repo = SqlAlchemyMagentoProductVersionRepository(session)
        notification_repo = SqlAlchemyMagentoSyncNotificationRepository(session)
        ingest_repo = SqlAlchemyIngestRepository(session)
        catalog_repo = SqlAlchemyMagentoCatalogStateRepository(session)
        attr_registry_repo = SqlAlchemyAttributeRegistryRepository(session)
        attr_set_attrs_repo = SqlAlchemyAttributeSetAttributesRepository(session)
        attr_set_registry_repo = SqlAlchemyAttributeSetRegistryRepository(session)
        cat_registry_repo = SqlAlchemyCategoryRegistryRepository(session)
        baseline_repo = SqlAlchemyBaselineSyncRunRepository(session)
        store_registry_repo = SqlAlchemyMagentoStoreRegistryRepository(session)
        source_repo = SqlAlchemySourceRegistryRepository(session)
        pending_repo = SqlAlchemyMagentoSyncPendingRepository(session)

        def _source_codes_provider(cid: int):
            codes = source_repo.get_enabled_source_codes(cid)
            if not codes:
                codes = source_repo.get_all_source_codes(cid)
            if not codes:
                configured = os.getenv("MAGENTO_DEFAULT_SOURCE_CODE", "").strip()
                if configured:
                    return [configured]
            return codes or ["default"]

        conn = conn_repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")

        from magento.sync_snapshot_loader import load_normalized_snapshot_rows

        snapshot_rows, sku_list, sync_meta = load_normalized_snapshot_rows(
            session,
            connection_id=connection_id,
            sku_list=skus,
        )
        if not sku_list:
            return {"job_id": None, "message": "No products to sync", "source": sync_meta.get("source")}

        job_id = sync_repo.create_sync_job(
            MagentoSyncJobCreate(connection_id=connection_id, dry_run=dry_run, total_count=len(snapshot_rows))
        )
        sync_repo.update_sync_job(job_id, status="running", started_at=datetime.utcnow())

        def resolve_url(url: str) -> str:
            if not url or not BASE_IMAGE_URL:
                return url
            if url.startswith(BASE_IMAGE_URL) and "/api/i/" in url:
                slug = url.rstrip("/").split("/")[-1]
                rec = resolve_slug(ingest_repo, slug)
                return rec.destination_url if rec else url
            return url

        oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
        api = MagentoRestClient(oauth)
        sync_svc = MagentoSyncService(
            api,
            conn,
            sync_state_repo=state_repo,
            media_map_repo=media_repo,
            sync_job_repo=sync_repo,
            override_repo=override_repo,
            version_repo=version_repo,
            notification_repo=notification_repo,
            attr_cache_repo=attr_registry_repo,
            category_cache_repo=cat_registry_repo,
            store_registry_repo=store_registry_repo,
            baseline_repo=baseline_repo,
            attr_set_attrs_repo=attr_set_attrs_repo,
            attr_set_registry_repo=attr_set_registry_repo,
            resolve_image_url=resolve_url,
            source_codes_provider=_source_codes_provider,
            sync_pending_repo=pending_repo,
        )

        catalog_media = catalog_repo.get_media_hashes_for_skus(connection_id, sku_list)
        catalog_state = catalog_repo.get_catalog_state_for_skus(connection_id, sku_list)
        result = sync_svc.sync_incremental(
            connection_id,
            snapshot_rows,
            job_id=job_id,
            dry_run=dry_run,
            state_repo=state_repo,
            job_repo=sync_repo,
            override_repo=override_repo,
            version_repo=version_repo,
            notification_repo=notification_repo,
            notify_email=None,
            catalog_state_media_by_sku=catalog_media,
            catalog_state_by_sku=catalog_state,
            force_relations=False,
        )
        session.commit()

    return {
        "job_id": result.get("job_id"),
        "planned_count": result.get("planned_count", 0),
        "success_count": result.get("success_count", 0),
        "error_count": result.get("error_count", 0),
        "dry_run": dry_run,
    }


@app.post("/api/magento/sync/incremental")
async def magento_sync_incremental(
    connection_id: int = Body(..., embed=True),
    dry_run: bool = Body(False, embed=True),
    skus: Optional[List[str]] = Body(None, embed=True),
    notify_email: Optional[str] = Body(None, embed=True),
    force_relations: bool = Body(False, embed=True),
    force_associations: bool = Body(False, embed=True),
    replace_options: bool = Body(False, embed=True),
    force_full_sync: bool = Body(False, embed=True),
):
    """
    Incremental sync: compares daily snapshot to magento_sync_state hashes,
    runs only UPSERT_PRODUCT, UPSERT_MEDIA, SET_RELATIONS, SET_ASSOCIATIONS when needed.
    If skus omitted, syncs all current products.
    force_full_sync=True: force UPSERT_PRODUCT, SET_RELATIONS, and SET_ASSOCIATIONS
    (related / upsell / crosssell) for all products.
    force_associations=True: force only the product-link pass.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    cfg = load_magento_config()
    registry_codes = load_attribute_registry_codes(load_db_config().attribute_registry_path)
    registry_options = load_attribute_registry_options(load_db_config().attribute_registry_path)

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        sync_repo = SqlAlchemyMagentoSyncRepository(session)
        state_repo = SqlAlchemyMagentoSyncStateRepository(session)
        media_repo = SqlAlchemyMagentoMediaMapRepository(session)
        override_repo = SqlAlchemyMagentoProductOverrideRepository(session)
        version_repo = SqlAlchemyMagentoProductVersionRepository(session)
        notification_repo = SqlAlchemyMagentoSyncNotificationRepository(session)
        ingest_repo = SqlAlchemyIngestRepository(session)
        catalog_repo = SqlAlchemyMagentoCatalogStateRepository(session)
        attr_registry_repo = SqlAlchemyAttributeRegistryRepository(session)
        attr_set_attrs_repo = SqlAlchemyAttributeSetAttributesRepository(session)
        attr_set_registry_repo = SqlAlchemyAttributeSetRegistryRepository(session)
        cat_registry_repo = SqlAlchemyCategoryRegistryRepository(session)
        baseline_repo = SqlAlchemyBaselineSyncRunRepository(session)
        store_registry_repo = SqlAlchemyMagentoStoreRegistryRepository(session)
        source_repo = SqlAlchemySourceRegistryRepository(session)
        pending_repo = SqlAlchemyMagentoSyncPendingRepository(session)

        def _source_codes_provider(cid: int):
            codes = source_repo.get_enabled_source_codes(cid)
            if not codes:
                codes = source_repo.get_all_source_codes(cid)
            if not codes:
                configured = os.getenv("MAGENTO_DEFAULT_SOURCE_CODE", "").strip()
                if configured:
                    return [configured]
            return codes or ["default"]

        conn = conn_repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")

        from magento.sync_snapshot_loader import load_normalized_snapshot_rows

        requested_skus = list(skus) if skus else None
        snapshot_rows, sku_list, sync_meta = load_normalized_snapshot_rows(
            session,
            connection_id=connection_id,
            sku_list=requested_skus,
        )
        if not sku_list:
            return {
                "job_id": None,
                "message": "No products to sync",
                "planned_count": 0,
                "source": sync_meta.get("source"),
            }

        auto_added_children: List[str] = []
        missing_from_magento: List[str] = []

        job_id = sync_repo.create_sync_job(
            MagentoSyncJobCreate(
                connection_id=connection_id,
                dry_run=dry_run,
                total_count=len(snapshot_rows),
            )
        )
        sync_repo.update_sync_job(job_id, status="running", started_at=datetime.utcnow())

        def resolve_url(url: str) -> str:
            if not url or not BASE_IMAGE_URL:
                return url
            if url.startswith(BASE_IMAGE_URL) and "/api/i/" in url:
                slug = url.rstrip("/").split("/")[-1]
                rec = resolve_slug(ingest_repo, slug)
                return rec.destination_url if rec else url
            return url

        oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
        api = MagentoRestClient(oauth)
        sync_svc = MagentoSyncService(
            api,
            conn,
            sync_state_repo=state_repo,
            media_map_repo=media_repo,
            sync_job_repo=sync_repo,
            override_repo=override_repo,
            version_repo=version_repo,
            notification_repo=notification_repo,
            attr_cache_repo=attr_registry_repo,
            category_cache_repo=cat_registry_repo,
            store_registry_repo=store_registry_repo,
            baseline_repo=baseline_repo,
            attr_set_attrs_repo=attr_set_attrs_repo,
            attr_set_registry_repo=attr_set_registry_repo,
            resolve_image_url=resolve_url,
            source_codes_provider=_source_codes_provider,
            sync_pending_repo=pending_repo,
        )
        # Fall back to EMAIL_TO_DEFAULT if no explicit notify_email given
        effective_notify_email = notify_email
        if not effective_notify_email:
            try:
                from settings import load_email_config
                _ecfg = load_email_config()
                if _ecfg.provider != "disabled" and _ecfg.to_default:
                    effective_notify_email = _ecfg.to_default
            except Exception:
                pass

        catalog_media = catalog_repo.get_media_hashes_for_skus(connection_id, sku_list)
        catalog_state = catalog_repo.get_catalog_state_for_skus(connection_id, sku_list)
        result = sync_svc.sync_incremental(
            connection_id,
            snapshot_rows,
            job_id=job_id,
            dry_run=dry_run,
            state_repo=state_repo,
            job_repo=sync_repo,
            override_repo=override_repo,
            version_repo=version_repo,
            notification_repo=notification_repo,
            notify_email=effective_notify_email,
            catalog_state_media_by_sku=catalog_media,
            catalog_state_by_sku=catalog_state,
            force_relations=force_relations,
            force_associations=force_associations,
            replace_options=replace_options,
            force_full_sync=force_full_sync,
        )
        session.commit()

    return {
        "job_id": result.get("job_id"),
        "planned_count": result.get("planned_count", 0),
        "success_count": result.get("success_count", 0),
        "error_count": result.get("error_count", 0),
        "dry_run": dry_run,
        "auto_added_children": auto_added_children,
        "missing_from_magento_before_sync": missing_from_magento,
        "total_skus_synced": len(sku_list),
    }


@app.api_route(
    "/api/magento/connections/{connection_id}/debug/compare-products",
    methods=["GET", "POST"],
)
async def magento_debug_compare_products(
    connection_id: int,
    good_sku: Optional[str] = Query(None, description="SKU that shows variations correctly (GET)"),
    bad_sku: Optional[str] = Query(None, description="SKU that does not show variations (GET)"),
    body: Optional[dict] = Body(None),
):
    """
    Debug endpoint: fetch full Magento records for two products (good + bad) including
    configurable options, children, and each child's full product record.
    Also fetches MSI (Multi-Source Inventory) data: source-items per SKU and available sources.
    Use to compare why one shows variations on frontend and the other does not, and to
    compare/apply MSI fixes (e.g. missing source assignment, status=0 vs 1).
    POST: pass JSON body {"good_sku": "...", "bad_sku": "..."}.
    GET: pass good_sku and bad_sku as query params.
    """
    good_sku = str((body or {}).get("good_sku") or good_sku or "").strip()
    bad_sku = str((body or {}).get("bad_sku") or bad_sku or "").strip()
    if not good_sku or not bad_sku:
        raise HTTPException(status_code=400, detail="good_sku and bad_sku required")

    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        conn = conn_repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")

        oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
        api = MagentoRestClient(oauth)

        def _fetch_full_product_record(sku: str) -> dict:
            prod = api.get_product(sku)
            if not prod:
                return {"error": f"Product not found: {sku}", "sku": sku}
            out = dict(prod)
            pt = str(prod.get("type_id", "")).strip().lower()
            if "configurable" in pt:
                status_opts, opts, _ = api.get_configurable_options(sku)
                out["_configurable_options"] = opts if status_opts == 200 else {"error": f"HTTP {status_opts}"}
                status_ch, children, _ = api.get_configurable_children(sku)
                if status_ch == 200 and children:
                    out["_child_skus"] = children
                    out["_children_full"] = []
                    for c_sku in children:
                        c_prod = api.get_product(c_sku)
                        out["_children_full"].append(c_prod if c_prod else {"error": f"Not found: {c_sku}", "sku": c_sku})
                else:
                    out["_child_skus"] = []
                    out["_children_full"] = []
            return out

        good_record = _fetch_full_product_record(good_sku)
        bad_record = _fetch_full_product_record(bad_sku)

        # Collect all SKUs (parents + children) for MSI lookup
        all_skus = list(dict.fromkeys([
            good_sku, bad_sku,
            *(good_record.get("_child_skus") or []),
            *(bad_record.get("_child_skus") or []),
        ]))

        # MSI: sources (available warehouses) and source-items per SKU
        msi_sources: List[dict] = []
        msi_by_sku: Dict[str, List[dict]] = {}
        status_src, sources, _ = api.get_inventory_sources()
        if status_src == 200 and sources:
            msi_sources = [{"source_code": s.get("source_code"), "name": s.get("name")} for s in sources]
        status_msi, msi_by_sku, _ = api.get_source_items_for_skus(all_skus)

        def _attach_msi(rec: dict) -> None:
            sku = rec.get("sku")
            if sku and "error" not in str(rec.get("error", "")):
                rec["_msi_source_items"] = msi_by_sku.get(sku, [])

        _attach_msi(good_record)
        _attach_msi(bad_record)
        for ch in good_record.get("_children_full") or []:
            _attach_msi(ch)
        for ch in bad_record.get("_children_full") or []:
            _attach_msi(ch)

        # Store / website hierarchy for scope debugging
        store_websites: List[dict] = []
        store_groups: List[dict] = []
        store_views: List[dict] = []
        st_web, web_body = api.get_store_websites()
        if st_web == 200 and web_body:
            store_websites = web_body
        st_grp, grp_body = api.get_store_groups()
        if st_grp == 200 and grp_body:
            store_groups = grp_body
        st_views, views_body = api.get_store_views()
        if st_views == 200 and views_body:
            store_views = views_body

        # Diagnostics: compare status, visibility, website_ids (explains Out of Stock, Search)
        def _extract_product_diag(rec: dict, label: str) -> dict:
            err = rec.get("error")
            if err:
                return {"label": label, "error": err}
            ext = rec.get("extension_attributes") or {}
            stock = ext.get("stock_item") or {}
            web_ids = ext.get("website_ids") or []
            return {
                "sku": rec.get("sku"),
                "type_id": rec.get("type_id"),
                "status": rec.get("status"),  # 1=enabled, 0=disabled
                "visibility": rec.get("visibility"),  # 1=Not Visible, 4=Catalog+Search
                "website_ids": web_ids,
                "stock_item_is_in_stock": stock.get("is_in_stock"),
                "stock_item_qty": stock.get("qty"),
                "msi_status": (
                    [si.get("status") for si in (rec.get("_msi_source_items") or [])]
                    if rec.get("_msi_source_items") else None
                ),
            }

        def _children_diag(rec: dict) -> List[dict]:
            return [_extract_product_diag(ch, ch.get("sku", "")) for ch in (rec.get("_children_full") or [])]

        good_ch = _children_diag(good_record)
        bad_ch = _children_diag(bad_record)
        good_ch_statuses = [c.get("status") for c in good_ch if "error" not in str(c.get("error", ""))]
        bad_ch_statuses = [c.get("status") for c in bad_ch if "error" not in str(c.get("error", ""))]
        good_ch_vis = [c.get("visibility") for c in good_ch if "error" not in str(c.get("error", ""))]
        bad_ch_vis = [c.get("visibility") for c in bad_ch if "error" not in str(c.get("error", ""))]

        diagnostics = {
            "summary": {
                "good_children_status": good_ch_statuses,
                "bad_children_status": bad_ch_statuses,
                "good_children_visibility": good_ch_vis,
                "bad_children_visibility": bad_ch_vis,
                "status_ok": all(s == 1 for s in good_ch_statuses) and all(s == 1 for s in bad_ch_statuses),
                "visibility_ok": all(v == 4 for v in good_ch_vis) and all(v == 4 for v in bad_ch_vis),
            },
            "good_parent": _extract_product_diag(good_record, "good"),
            "bad_parent": _extract_product_diag(bad_record, "bad"),
            "good_children": good_ch,
            "bad_children": bad_ch,
            "notes": [
                "status=0 means DISABLED (product hidden, configurable appears Out of Stock)",
                "visibility: 1=Not Visible Individually, 2=Catalog, 3=Search, 4=Catalog+Search",
                "Children with status=0 or visibility=1 will not show in search or as saleable variants",
                "If MSI has status=1 but stock_item.is_in_stock=false, run: bin/magento indexer:reindex cataloginventory_stock inventory",
                "If product not in search, run: bin/magento indexer:reindex catalogsearch_fulltext catalog_product_attribute",
            ],
        }

    return {
        "good_sku": good_sku,
        "bad_sku": bad_sku,
        "good": _sanitize_for_json(good_record),
        "bad": _sanitize_for_json(bad_record),
        "msi_sources": msi_sources,
        "msi_fetch_ok": status_msi == 200,
        "store_hierarchy": {
            "websites": store_websites,
            "store_groups": store_groups,
            "store_views": store_views,
        },
        "diagnostics": _sanitize_for_json(diagnostics),
    }


@app.post("/api/magento/connections/{connection_id}/debug/clear-cache")
async def magento_debug_clear_cache(connection_id: int):
    """
    Clear Magento caches for a connection:
    - Attribute cache (attribute_id, option value_index) — helps with wrong axes/option replacement
    - Category cache — helps with category path resolution
    - MSI mode cache (in-memory) — helps after Magento inventory changes

    Next sync will refetch from Magento. Use when cache may be stale.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        conn = conn_repo.get_by_id(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")

        attr_cache_repo = SqlAlchemyMagentoAttributeCacheRepository(session)
        cat_cache_repo = SqlAlchemyMagentoCategoryCacheRepository(session)
        attrs_deleted, opts_deleted = attr_cache_repo.clear_for_connection(connection_id)
        cats_deleted = cat_cache_repo.clear_for_connection(connection_id)
        session.commit()

    from magento.inventory_service import clear_msi_mode_cache
    clear_msi_mode_cache(connection_id)

    return {
        "status": "ok",
        "connection_id": connection_id,
        "attribute_cache_deleted": attrs_deleted,
        "attribute_option_cache_deleted": opts_deleted,
        "category_cache_deleted": cats_deleted,
        "msi_mode_cache_cleared": True,
    }


@app.post("/api/magento/connections/{connection_id}/debug/sync-sku")
async def magento_debug_sync_sku(
    connection_id: int,
    sku: str = Body(..., embed=True),
    force_relations: bool = Body(True, embed=True),
    force_upsert: bool = Body(True, embed=True),
    replace_options: bool = Body(False, embed=True),
    debug_height: bool = Body(False, embed=True),
):
    """
    Debug endpoint: run sync for a single configurable parent SKU (+ children)
    and return a full trace of planned actions and Magento API calls.

    debug_height=True: enables INFO logging for height axis injection (parent variant_list,
    configurable_variations, child_to_mapping, value resolution). Use to debug "child doesn't
    have height attribute" errors. Or set MAGENTO_DEBUG_HEIGHT=1 env var.

    force_upsert=True (default): force UPSERT_PRODUCT for all SKUs (parent+children),
    fixing existing products (status=1, categories, MSI source). Set false to only sync changed.
    Use this to debug why categories/variations are not being applied.
    Executes the real sync (writes to Magento). Response includes:
    - sku_list: SKUs included (parent + children)
    - snapshot_row: normalized feed row for the parent
    - planned_actions: what the planner scheduled
    - sync_result: outcome from sync_incremental
    - api_trace: every Magento API call (method, path, request, response, status)
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    sku = str(sku).strip()
    if not sku:
        raise HTTPException(status_code=400, detail="sku required")

    cfg = load_magento_config()
    registry_codes = load_attribute_registry_codes(load_db_config().attribute_registry_path)
    registry_options = load_attribute_registry_options(load_db_config().attribute_registry_path)

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        sync_repo = SqlAlchemyMagentoSyncRepository(session)
        state_repo = SqlAlchemyMagentoSyncStateRepository(session)
        media_repo = SqlAlchemyMagentoMediaMapRepository(session)
        override_repo = SqlAlchemyMagentoProductOverrideRepository(session)
        version_repo = SqlAlchemyMagentoProductVersionRepository(session)
        notification_repo = SqlAlchemyMagentoSyncNotificationRepository(session)
        ingest_repo = SqlAlchemyIngestRepository(session)
        catalog_repo = SqlAlchemyMagentoCatalogStateRepository(session)
        attr_registry_repo = SqlAlchemyAttributeRegistryRepository(session)
        attr_set_attrs_repo = SqlAlchemyAttributeSetAttributesRepository(session)
        attr_set_registry_repo = SqlAlchemyAttributeSetRegistryRepository(session)
        cat_registry_repo = SqlAlchemyCategoryRegistryRepository(session)
        baseline_repo = SqlAlchemyBaselineSyncRunRepository(session)
        store_registry_repo = SqlAlchemyMagentoStoreRegistryRepository(session)
        source_repo = SqlAlchemySourceRegistryRepository(session)
        pending_repo = SqlAlchemyMagentoSyncPendingRepository(session)

        def _source_codes_provider(cid: int):
            codes = source_repo.get_enabled_source_codes(cid)
            if not codes:
                codes = source_repo.get_all_source_codes(cid)
            if not codes:
                configured = os.getenv("MAGENTO_DEFAULT_SOURCE_CODE", "").strip()
                if configured:
                    return [configured]
            return codes or ["default"]

        conn = conn_repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")

        sku_list = [sku]
        # Collect children from both: variant_of on child rows AND parent's variant_list/configurable_variations
        children_from_db = ingest_repo.load_children_of_parents([sku])
        parent_snapshots = ingest_repo.load_current_snapshots([sku])
        children_from_attrs: List[str] = []
        if parent_snapshots:
            from magento.sync_hashes import _parse_children_from_row
            for snap in parent_snapshots:
                attrs = snap.get("attrs", {}) or {}
                row = {**{k: v for k, v in snap.items() if k != "attrs"}, **(attrs or {})}
                ch, _, _ = _parse_children_from_row(row)
                children_from_attrs.extend(c for c in ch if c and c not in children_from_attrs)
        children = list(dict.fromkeys(children_from_db + children_from_attrs))
        if children:
            sku_list = list(set(sku_list + children))

        snapshots = ingest_repo.load_current_snapshots(sku_list)
        if not snapshots:
            return {
                "error": f"No snapshot found for SKU '{sku}' or its children",
                "sku_list": sku_list,
                "children": children or [],
                "children_from_attrs": children_from_attrs if children_from_attrs else [],
                "children_from_db": children_from_db if children_from_db else [],
                "planned_actions": [],
                "api_trace": [],
            }

        prices = ingest_repo.load_current_prices_for_skus(sku_list)
        overrides = ingest_repo.load_overrides_for_skus(sku_list)
        snapshot_df = build_dataframe_from_snapshots(snapshots)
        for idx, row in snapshot_df.iterrows():
            s = str(row.get("sku", "")).strip()
            if s and s in prices:
                p = prices[s]
                snapshot_df.at[idx, "price"] = p.get("price")
        snapshot_df = apply_overrides(snapshot_df, overrides)

        normalized_df, quarantine_df = normalize_plytix_df(
            snapshot_df,
            cfg,
            allowed_attribute_codes=registry_codes or None,
            allowed_attribute_options=registry_options or None,
            enforce_required_name=False,
        )
        snapshot_rows = normalized_df.to_dict(orient="records")

        sku_norm = sku.strip().lower()
        parent_row = next(
            (r for r in snapshot_rows if str(r.get("sku", "")).strip().lower() == sku_norm),
            None,
        )
        # When parent is missing, check quarantine for diagnostics (e.g. "No child had complete axis values")
        quarantine_diagnostics = None
        if parent_row is None and not quarantine_df.empty and "sku" in quarantine_df.columns:
            parent_quarantine = quarantine_df[
                quarantine_df["sku"].fillna("").astype(str).str.strip().str.lower() == sku_norm
            ]
            if not parent_quarantine.empty:
                quarantine_diagnostics = parent_quarantine.to_dict(orient="records")

        api_trace: List[Dict] = []
        tracing_oauth = TracingOAuthClient(
            api_trace=api_trace,
            base_url=conn["magento_api_base_url"],
            consumer_key=conn["consumer_key"],
            consumer_secret=conn["consumer_secret"],
            access_token=conn["access_token"],
            access_token_secret=conn["access_token_secret"],
            rest_base_path=conn["rest_base_path"],
            signature_method=conn.get("signature_method", "HMAC-SHA256"),
        )
        api = MagentoRestClient(tracing_oauth)

        def resolve_url(url: str) -> str:
            if not url or not BASE_IMAGE_URL:
                return url
            if url.startswith(BASE_IMAGE_URL) and "/api/i/" in url:
                slug = url.rstrip("/").split("/")[-1]
                rec = resolve_slug(ingest_repo, slug)
                return rec.destination_url if rec else url
            return url

        sync_svc = MagentoSyncService(
            api,
            conn,
            sync_state_repo=state_repo,
            media_map_repo=media_repo,
            sync_job_repo=sync_repo,
            override_repo=override_repo,
            version_repo=version_repo,
            notification_repo=notification_repo,
            attr_cache_repo=attr_registry_repo,
            category_cache_repo=cat_registry_repo,
            store_registry_repo=store_registry_repo,
            baseline_repo=baseline_repo,
            attr_set_attrs_repo=attr_set_attrs_repo,
            attr_set_registry_repo=attr_set_registry_repo,
            resolve_image_url=resolve_url,
            source_codes_provider=_source_codes_provider,
            sync_pending_repo=pending_repo,
        )

        catalog_media = catalog_repo.get_media_hashes_for_skus(connection_id, sku_list)
        catalog_state = catalog_repo.get_catalog_state_for_skus(connection_id, sku_list)

        sync_state_before = state_repo.get_states_for_skus(connection_id, sku_list)

        if debug_height:
            os.environ["MAGENTO_DEBUG_HEIGHT"] = "1"
        try:
            job_id = sync_repo.create_sync_job(
                MagentoSyncJobCreate(
                    connection_id=connection_id,
                    dry_run=False,
                    total_count=len(snapshot_rows),
                )
            )
            sync_repo.update_sync_job(job_id, status="running", started_at=datetime.utcnow())
            result = sync_svc.sync_incremental(
                connection_id,
                snapshot_rows,
                job_id=job_id,
                dry_run=False,
                state_repo=state_repo,
                job_repo=sync_repo,
                override_repo=override_repo,
                version_repo=version_repo,
                notification_repo=notification_repo,
                catalog_state_media_by_sku=catalog_media,
                catalog_state_by_sku=catalog_state,
                force_relations=force_relations,
                replace_options=replace_options,
                force_upsert_skus=sku_list if force_upsert else None,
                return_plan=True,
                return_action_details=True,
            )
        finally:
            if debug_height:
                os.environ.pop("MAGENTO_DEBUG_HEIGHT", None)
        session.commit()

        planned_actions = result.get("planned_actions", [])

        # POST-SYNC: verify relations and inventory for diagnostics
        inventory_diagnostics: Dict[str, Any] = {}
        why_admin_may_hide_children: Optional[str] = None
        expected_children_count = len(children) if children else 0
        actual_children_count: Optional[int] = None
        action_details = result.get("action_details", [])

        if parent_row and children:
            try:
                ch_status, actual_children, _ = api.get_configurable_children(sku)
                if ch_status == 200 and actual_children is not None:
                    actual_children_count = len(actual_children)
                    if actual_children_count != expected_children_count:
                        why_admin_may_hide_children = (
                            f"Relations OK (GET children returns {actual_children_count}). "
                            f"Expected {expected_children_count}. "
                            "Admin may hide children if MSI source_items missing for some SKUs."
                        )
            except Exception as e:
                why_admin_may_hide_children = f"Could not verify: {e}"

            # Fetch current source-items for child SKUs
            try:
                src_status, src_by_sku, _ = api.get_source_items_for_skus(children)
                if src_status == 200 and src_by_sku:
                    child_inventory = {}
                    for csku in children:
                        items = src_by_sku.get(csku, [])
                        child_inventory[csku] = {
                            "msi_source_items_count": len(items),
                            "source_items": [
                                {"source_code": x.get("source_code"), "quantity": x.get("quantity"), "status": x.get("status")}
                                for x in items[:5]
                            ],
                        }
                    inventory_diagnostics["children_source_items"] = child_inventory
            except Exception:
                pass

        for ad in action_details:
            if ad.get("action") == "UPSERT_PRODUCT" and ad.get("sku") in children:
                inventory_diagnostics.setdefault("child_inventory_writes", {})[ad["sku"]] = {
                    "msi_mode_detected": ad.get("msi_mode_detected"),
                    "source_items_written": ad.get("source_items_written"),
                    "legacy_stock_written": ad.get("legacy_stock_written"),
                    "inventory_error": ad.get("inventory_error"),
                    "inventory_action_taken": ad.get("inventory_action_taken"),
                    "inventory_before": ad.get("inventory_before"),
                    "inventory_after": ad.get("inventory_after"),
                }

    # E: Debug trace - product_exists_before, env flags, source codes
    product_exists_before_map: Dict[str, bool] = {}
    for s in sku_list:
        cat = catalog_state.get(s) if catalog_state else {}
        product_exists_before_map[s] = bool(cat and cat.get("magento_product_id"))

    env_flags: Dict[str, Any] = {}
    try:
        from settings import load_magento_msi_config, load_magento_media_config
        msi_cfg = load_magento_msi_config()
        media_cfg = load_magento_media_config()
        env_flags = {
            "MAGENTO_MSI_DEFAULT_QTY_IF_MISSING": msi_cfg.default_qty_if_missing,
            "MAGENTO_MSI_FORCE_ZERO_QTY_TO_DEFAULT": msi_cfg.force_zero_qty_to_default,
            "MAGENTO_MSI_ASSIGN_ALL_SOURCES_IF_NONE": msi_cfg.assign_all_sources_if_none,
            "MAGENTO_MSI_ASSIGN_ALL_SOURCES_IF_PARTIAL": msi_cfg.assign_all_sources_if_partial,
            "MAGENTO_MSI_PRESERVE_EXISTING_POSITIVE_QTY": msi_cfg.preserve_existing_positive_qty,
            "MAGENTO_MEDIA_REQUIRE_PRODUCT_EXISTENCE": media_cfg.require_product_existence,
        }
    except Exception:
        pass

    source_codes_considered = _source_codes_provider(connection_id)

    # Enrich action_details with product_exists_before
    enriched_details = []
    for ad in result.get("action_details", []):
        ad_copy = dict(ad)
        ad_copy["product_exists_before"] = product_exists_before_map.get(ad.get("sku", ""), False)
        enriched_details.append(ad_copy)

    payload = {
        "sku": sku,
        "sku_list": sku_list,
        "debug_height_enabled": debug_height,
        "children": children or [],
        "children_from_attrs": children_from_attrs if children_from_attrs else [],
        "children_from_db": children_from_db if children_from_db else [],
        "snapshot_row": parent_row,
        "quarantine_diagnostics": _sanitize_for_json(quarantine_diagnostics) if quarantine_diagnostics else None,
        "sync_state_before": sync_state_before,
        "planned_count": result.get("planned_count", 0),
        "planned_actions": planned_actions,
        "sync_result": {
            "status": result.get("status"),
            "success_count": result.get("success_count"),
            "error_count": result.get("error_count"),
            "job_id": job_id,
        },
        "api_trace": api_trace,
        "inventory_diagnostics": _sanitize_for_json(inventory_diagnostics),
        "expected_children_count": expected_children_count,
        "actual_children_count": actual_children_count,
        "why_admin_may_hide_children": why_admin_may_hide_children,
        "action_details": _sanitize_for_json(enriched_details),
        "product_exists_before": product_exists_before_map,
        "env_flags_resolved": env_flags,
        "source_codes_considered": source_codes_considered,
    }
    return JSONResponse(content=_sanitize_for_json(payload))


@app.get("/api/magento/sync/failed-items")
async def magento_sync_failed_items(
    connection_id: int,
    action: Optional[str] = Query(None, description="Filter by action, e.g. SET_RELATIONS"),
    error_like: Optional[str] = Query(None, description="Filter by error pattern, e.g. 'same axis'"),
    limit: int = Query(500, ge=1, le=2000, description="Max items to return"),
):
    """
    List SKUs that failed during sync, with their error messages.

    Use action=SET_RELATIONS to find configurable parents whose relations failed
    (e.g. 'all configurations have same axis values').
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        sync_repo = SqlAlchemyMagentoSyncRepository(session)
        conn = conn_repo.get_by_id(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")
        items = sync_repo.get_failed_sync_items(
            connection_id,
            action=action,
            error_like=error_like,
            limit=limit,
            unique_skus=True,
        )
    # Extract short error message (drop Magento trace)
    for it in items:
        it["error"] = _extract_error_message_short(it.get("error"))
    return {
        "connection_id": connection_id,
        "count": len(items),
        "items": _sanitize_for_json(items),
    }


@app.post("/api/magento/sync/failed-items/resolve")
async def magento_resolve_failed_items(
    connection_id: int = Body(..., embed=True),
    skus: Optional[List[str]] = Body(None, embed=True),
    all_failed: bool = Body(False, embed=True, alias="all"),
):
    """
    Mark failed sync items as resolved. Use when products are confirmed active on Magento.
    Either pass skus or all=true. Clears last_error in sync_state and marks items succeeded.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    if not skus and not all_failed:
        raise HTTPException(status_code=400, detail="Provide skus or all=true")

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        conn = conn_repo.get_by_id(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")
        sync_repo = SqlAlchemyMagentoSyncRepository(session)
        state_repo = SqlAlchemyMagentoSyncStateRepository(session)
        count = sync_repo.mark_failed_items_resolved(
            connection_id,
            skus=skus if skus else None,
            state_repo=state_repo,
        )
        session.commit()
    return {"connection_id": connection_id, "items_resolved": count}


@app.post("/api/magento/sync/pause")
async def magento_sync_pause(
    connection_id: Optional[int] = Body(None, embed=True),
    disable_schedules: bool = Body(True, embed=True),
    cancel_channel_jobs: bool = Body(True, embed=True),
):
    """
    Stop automated Magento sync for a connection:
    - cancel queued magento_sync_queue rows
    - optionally disable push schedules (channel_schedule — managed in UI)
    - optionally cancel queued channel_job rows delegated to Magento push

    On-demand push from the Publish page is unaffected; re-enable schedules on #/scheduled when ready.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from datetime import datetime

    from sqlalchemy import select

    from db.magento_repositories import SqlAlchemyMagentoSyncQueueRepository
    from db.models import ChannelJob, ChannelSchedule

    with get_session() as session:
        cancelled_queue = SqlAlchemyMagentoSyncQueueRepository(session).cancel_queued(
            connection_id=connection_id
        )

        disabled_schedule_ids: List[int] = []
        if disable_schedules:
            stmt = select(ChannelSchedule).where(
                ChannelSchedule.is_enabled.is_(True),
                ChannelSchedule.channel_type == "magento",
                ChannelSchedule.task_type == "push",
            )
            if connection_id is not None:
                stmt = stmt.where(ChannelSchedule.native_connection_id == connection_id)
            for row in session.scalars(stmt).all():
                row.is_enabled = False
                row.next_run_at = None
                disabled_schedule_ids.append(int(row.id))

        cancelled_jobs = 0
        if cancel_channel_jobs:
            job_stmt = select(ChannelJob).where(ChannelJob.status == "queued")
            if connection_id is not None:
                job_stmt = job_stmt.where(
                    ChannelJob.channel_type == "magento",
                    ChannelJob.native_connection_id == connection_id,
                )
            else:
                job_stmt = job_stmt.where(ChannelJob.channel_type == "magento")
            for job in session.scalars(job_stmt).all():
                job.status = "cancelled"
                job.finished_at = datetime.utcnow()
                job.notes = "cancelled by magento sync pause"
                cancelled_jobs += 1

        session.commit()

    return {
        "status": "ok",
        "connection_id": connection_id,
        "cancelled_queue": cancelled_queue,
        "disabled_schedules": disabled_schedule_ids,
        "cancelled_channel_jobs": cancelled_jobs,
        "note": (
            "Automated Magento push is paused. Re-enable schedules on the Scheduled page (#/scheduled) "
            "or use Publish for one-off pushes."
        ),
    }


@app.get("/api/magento/sync/status")
async def magento_sync_status(connection_id: Optional[int] = None):
    """
    SKU-level diff report: what is in sync, what is pending, what has errors.
    No writes. Safe to call anytime.

    Returns:
    - not_in_magento:    Plytix SKUs with no magento_product_id in catalog_state (never pulled = likely don't exist)
    - never_pushed:      Plytix SKUs we have never pushed via incremental sync
    - has_errors:        SKUs with last_error in sync_state
    - in_magento_not_plytix: SKUs in catalog_state but not in Plytix (Magento-only / orphans)
    - in_sync:           SKUs in Plytix, pushed, and no current errors (count only for performance)
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        state_repo = SqlAlchemyMagentoSyncStateRepository(session)
        catalog_repo = SqlAlchemyMagentoCatalogStateRepository(session)
        ingest_repo = SqlAlchemyIngestRepository(session)

        if connection_id is None:
            from sqlalchemy import func, select
            from db.models import MagentoSyncJob
            plytix_skus = set(ingest_repo.load_all_current_skus())
            success_24h = session.scalar(
                select(func.count())
                .select_from(MagentoSyncJob)
                .where(MagentoSyncJob.finished_at >= datetime.utcnow() - timedelta(hours=24))
                .where(MagentoSyncJob.status.in_(["completed", "done", "success"]))
            ) or 0
            failed_24h = session.scalar(
                select(func.count())
                .select_from(MagentoSyncJob)
                .where(MagentoSyncJob.finished_at >= datetime.utcnow() - timedelta(hours=24))
                .where(MagentoSyncJob.status == "failed")
            ) or 0
            in_flight = session.scalar(
                select(func.count())
                .select_from(MagentoSyncJob)
                .where(MagentoSyncJob.status.in_(["started", "running", "queued"]))
            ) or 0
            return {
                "connection_id": None,
                "total_skus": len(plytix_skus),
                "recent_success_24h": success_24h,
                "recent_failed_24h": failed_24h,
                "in_flight": in_flight,
                "summary": {
                    "plytix_total": len(plytix_skus),
                    "in_magento": 0,
                    "not_in_magento": 0,
                    "never_pushed_by_us": 0,
                    "has_errors": 0,
                    "in_magento_not_plytix": 0,
                    "in_sync_approx": 0,
                },
            }

        conn = conn_repo.get_by_id(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")

        plytix_skus: set = set(ingest_repo.load_all_current_skus())
        all_states: dict = state_repo.get_all_states_for_connection(connection_id)
        pushed_skus: set = set(all_states.keys())
        magento_skus: set = catalog_repo.get_existing_magento_skus(connection_id, list(plytix_skus | pushed_skus))

        never_pushed = sorted(plytix_skus - pushed_skus)
        not_in_magento = sorted(plytix_skus - magento_skus)
        in_magento_not_plytix = sorted(magento_skus - plytix_skus)

        has_errors = [
            {"sku": s["sku"], "last_pushed_at": s["last_pushed_at"], "error": s["last_error"]}
            for s in all_states.values()
            if s.get("last_error") and s["sku"] in plytix_skus
        ]
        has_errors.sort(key=lambda x: x["sku"])

        pushed_no_error = pushed_skus & plytix_skus
        in_sync_count = len(pushed_no_error) - len(has_errors)

    return {
        "connection_id": connection_id,
        "summary": {
            "plytix_total": len(plytix_skus),
            "in_magento": len(magento_skus & plytix_skus),
            "not_in_magento": len(not_in_magento),
            "never_pushed_by_us": len(never_pushed),
            "has_errors": len(has_errors),
            "in_magento_not_plytix": len(in_magento_not_plytix),
            "in_sync_approx": max(in_sync_count, 0),
        },
        "not_in_magento": not_in_magento,
        "never_pushed_by_us": never_pushed,
        "has_errors": has_errors,
        "in_magento_not_plytix": in_magento_not_plytix,
    }


@app.get("/api/magento/report/datewise")
async def magento_report_datewise(
    connection_id: int,
    date_from: str,
    date_to: str,
):
    """Datewise report: jobs count, success/failed, breakdown by action, failed SKUs."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from datetime import date
    try:
        d_from = date.fromisoformat(date_from)
        d_to = date.fromisoformat(date_to)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        sync_repo = SqlAlchemyMagentoSyncRepository(session)
        conn = conn_repo.get_by_id(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")
        report = sync_repo.get_datewise_report(connection_id, d_from, d_to)
    return {"connection_id": connection_id, "date_from": date_from, "date_to": date_to, "days": report}


@app.post("/api/magento/rollback")
async def magento_rollback(
    connection_id: int = Body(..., embed=True),
    sku: Optional[str] = Body(None, embed=True),
    all_skus: bool = Body(False, embed=True),
    version_id: Optional[int] = Body(None, embed=True),
    as_of: Optional[str] = Body(None, embed=True),
):
    """Rollback products to a previous version. Provide version_id or as_of (ISO datetime)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from datetime import datetime
    from magento.rollback_service import MagentoRollbackService
    as_of_dt = None
    if as_of:
        try:
            as_of_dt = datetime.fromisoformat(as_of.replace("Z", "+00:00"))
        except ValueError:
            raise HTTPException(status_code=400, detail="Invalid as_of format (use ISO 8601)")
    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        state_repo = SqlAlchemyMagentoSyncStateRepository(session)
        version_repo = SqlAlchemyMagentoProductVersionRepository(session)
        conn = conn_repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")
        oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
        api = MagentoRestClient(oauth)
        sync_svc = MagentoSyncService(
            api, conn,
            sync_state_repo=state_repo,
            media_map_repo=SqlAlchemyMagentoMediaMapRepository(session),
            override_repo=SqlAlchemyMagentoProductOverrideRepository(session),
            resolve_image_url=lambda u: u,
        )
        rollback_svc = MagentoRollbackService(
            api, connection_id,
            version_repo=version_repo,
            state_repo=state_repo,
            media_executor=sync_svc,
            relations_executor=sync_svc,
        )
        result = rollback_svc.rollback_to_version(
            sku=sku, all_skus=all_skus, version_id=version_id, as_of=as_of_dt
        )
        session.commit()
    return result


@app.get("/api/magento/jobs/{job_id}")
async def magento_get_job(job_id: int):
    """Get sync job status and items."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.models import MagentoSyncJob
    with get_session() as session:
        sync_repo = SqlAlchemyMagentoSyncRepository(session)
        items = sync_repo.get_job_items(job_id)
        job = session.get(MagentoSyncJob, job_id)
        if not job:
            raise HTTPException(status_code=404, detail="Job not found")
        job_data = {
            "id": job.id,
            "connection_id": job.connection_id,
            "status": job.status,
            "dry_run": job.dry_run,
            "total_count": job.total_count,
            "success_count": job.success_count,
            "error_count": job.error_count,
            "started_at": job.started_at.isoformat() if job.started_at else None,
            "finished_at": job.finished_at.isoformat() if job.finished_at else None,
            "items": items,
        }
    return job_data


@app.post("/api/magento/media/cleanup-orphans", status_code=200)
async def magento_cleanup_orphaned_media(
    connection_id: int = Query(..., gt=0),
    sku: Optional[str] = Query(
        None,
        min_length=1,
        description="Single-SKU sync cleanup (legacy). Omit when using body skus/all_assigned.",
    ),
    dry_run: bool = Query(False),
    purge_unmapped: bool = Query(
        True,
        description="Delete live Magento gallery entries not mapped to current master URLs (orphan-only; default true).",
    ),
    payload: Optional[dict] = Body(None),
):
    """Remove Magento gallery orphans so remote keeps only master images.

    Modes:
    - Single SKU (query ``sku``): runs inline and returns per-SKU result.
    - Batch (body ``skus`` and/or ``all_assigned``): enqueues a ``media_cleanup``
      channel job that processes SKUs in batches (default 250). Poll ``poll_url``.

    Never wipes galleries and never re-uploads images — orphans only.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    body = payload if isinstance(payload, dict) else {}
    body_skus_raw = body.get("skus")
    body_skus: List[str] = []
    if isinstance(body_skus_raw, str):
        body_skus = [part.strip() for part in body_skus_raw.replace(";", ",").split(",") if part.strip()]
    elif isinstance(body_skus_raw, list):
        for item in body_skus_raw:
            for part in str(item or "").replace(";", ",").split(","):
                clean = part.strip()
                if clean:
                    body_skus.append(clean)
    all_assigned = bool(body.get("all_assigned"))
    batch_size = int(body.get("batch_size") or 250)
    offset = int(body.get("offset") or 0)
    limit = body.get("limit")
    if limit is not None:
        limit = int(limit)
    async_requested = body.get("async")
    clean_sku = str(sku or "").strip() or None
    batch_mode = all_assigned or bool(body_skus)
    if not batch_mode and not clean_sku:
        raise HTTPException(
            status_code=400,
            detail='Provide query sku=... or body {"skus":[...]} / {"all_assigned":true}',
        )

    run_async = bool(async_requested) if async_requested is not None else batch_mode
    if batch_mode and run_async:
        from app.jobs.channel_jobs import enqueue_magento_media_cleanup_job, job_to_dict

        with get_session() as session:
            connection = _connection_row(session, connection_id)
            if not connection or connection["channel_type"] != "magento":
                raise HTTPException(status_code=404, detail="Connection not found")
            job = enqueue_magento_media_cleanup_job(
                session,
                channel_connection_id=connection["id"],
                native_connection_id=connection["native_id"],
                channel_code=_channel_code_for_connection(connection),
                skus=body_skus or None,
                all_assigned=all_assigned,
                batch_size=batch_size,
                offset=offset,
                limit=limit,
                purge_unmapped=purge_unmapped,
                dry_run=dry_run,
                notes=(
                    "magento orphan media cleanup (all assigned)"
                    if all_assigned
                    else f"magento orphan media cleanup ({len(body_skus)} sku(s))"
                ),
            )
            data = job_to_dict(job)
            session.commit()
        return JSONResponse(
            status_code=202,
            content={
                "status": "queued",
                "mode": "orphans_only",
                "dry_run": dry_run,
                "purge_unmapped": purge_unmapped,
                "batch_size": batch_size,
                "all_assigned": all_assigned,
                "sku_count_requested": None if all_assigned else len(body_skus),
                "offset": offset,
                "limit": limit,
                "job_id": data["id"],
                "job": data,
                "poll_url": f"/api/channel-jobs/{data['id']}",
                "message": (
                    "Accepted; poll poll_url for progress. "
                    "Removes orphan gallery entries only (no wipe, no re-upload)."
                ),
            },
        )

    from magento.media_cleanup import (
        cleanup_orphaned_media_batch,
        desired_image_urls_for_sku,
        resolve_orphan_cleanup_skus,
    )

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        media_repo = SqlAlchemyMagentoMediaMapRepository(session)
        catalog_repo = SqlAlchemyMagentoCatalogStateRepository(session)
        conn = conn_repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")

        oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
        api = MagentoRestClient(oauth)

        if batch_mode:
            targets = resolve_orphan_cleanup_skus(
                session,
                skus=body_skus or None,
                all_assigned=all_assigned,
                offset=offset,
                limit=limit,
            )
            result = cleanup_orphaned_media_batch(
                connection_id=connection_id,
                skus=targets,
                api=api,
                media_repo=media_repo,
                catalog_repo=catalog_repo,
                session=session,
                dry_run=dry_run,
                purge_unmapped=purge_unmapped,
                batch_size=batch_size,
                include_sku_results=True,
            )
            if not dry_run:
                session.commit()
            return result

        desired_urls = desired_image_urls_for_sku(session, clean_sku)
        result = cleanup_orphaned_product_media(
            connection_id=connection_id,
            sku=clean_sku,
            desired_urls=desired_urls,
            api=api,
            media_repo=media_repo,
            catalog_repo=catalog_repo,
            dry_run=dry_run,
            purge_unmapped=purge_unmapped,
        )
        if result.get("status") == "failed":
            logger.warning(
                "magento cleanup-orphans failed: connection_id=%s sku=%s dry_run=%s upstream_status=%s error=%s",
                connection_id,
                clean_sku,
                dry_run,
                result.get("upstream_status"),
                result.get("error"),
            )
            if dry_run:
                return result
            raise HTTPException(
                status_code=502,
                detail={
                    "message": result.get("error") or "Magento media cleanup failed",
                    "upstream_status": result.get("upstream_status"),
                    "sku": clean_sku,
                    "connection_id": connection_id,
                },
            )
        if not dry_run:
            session.commit()

    return result


# GET and HEAD both allowed; redirect to Plytix destination so the client fetches the image from Plytix (no proxy cost).
@app.api_route("/api/i/{slug}", methods=["GET", "HEAD"])
async def redirect_short_image_url(slug: str):
    """Redirect to the Plytix destination URL; the client fetches the image directly from Plytix."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=503, detail="DB is not enabled")

    with get_session() as session:
        repo = SqlAlchemyIngestRepository(session)
        record = resolve_slug(repo, slug)

    if record is None:
        raise HTTPException(status_code=404, detail="Short URL not found")

    from fastapi.responses import RedirectResponse
    return RedirectResponse(url=record.destination_url, status_code=302)


@app.post("/api/magento/connections/{connection_id}/baseline/refresh")
async def magento_baseline_refresh(connection_id: int):
    """Force-refresh baseline sync (stores, categories, attributes, options) for a connection."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.magento_baseline_sync import run_baseline_sync
    result = run_baseline_sync(connection_id, force=True)
    if result.get("status") == "failed":
        raise HTTPException(status_code=500, detail=result.get("error", "Baseline sync failed"))
    return result


@app.post("/api/magento/connections/{connection_id}/attributes/refresh")
async def magento_attributes_refresh(connection_id: int):
    """Force-refresh attribute + option registry via baseline sync."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.magento_baseline_sync import run_baseline_sync
    result = run_baseline_sync(connection_id, force=True)
    if result.get("status") == "failed":
        raise HTTPException(status_code=500, detail=result.get("error", "Baseline sync failed"))
    counts = result.get("counts", {})
    return {"status": "ok", "refreshed_attributes": counts.get("attributes", 0), "refreshed_options": counts.get("options", 0)}


@app.post("/api/magento/connections/{connection_id}/categories/refresh")
async def magento_categories_refresh(connection_id: int):
    """Force-refresh category registry via baseline sync."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.magento_baseline_sync import run_baseline_sync
    result = run_baseline_sync(connection_id, force=True)
    if result.get("status") == "failed":
        raise HTTPException(status_code=500, detail=result.get("error", "Baseline sync failed"))
    counts = result.get("counts", {})
    return {"status": "ok", "cached_categories": counts.get("categories", 0)}


@app.get("/api/magento/connections/{connection_id}/msi/sources")
async def magento_msi_sources(connection_id: int):
    """List MSI sources from magento_source_registry (run baseline sync first)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.magento_repositories import SqlAlchemySourceRegistryRepository
    with get_session() as session:
        repo = SqlAlchemySourceRegistryRepository(session)
        codes = repo.get_all_source_codes(connection_id)
        return {"connection_id": connection_id, "sources": codes}


@app.get("/api/magento/connections/{connection_id}/msi/stocks")
async def magento_msi_stocks(connection_id: int):
    """List MSI stocks from magento_stock_registry (run baseline sync first)."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.magento_repositories import SqlAlchemyStockRegistryRepository, SqlAlchemyStockSourceLinksRepository
    from sqlalchemy import select
    from db.models import MagentoStockRegistry
    with get_session() as session:
        stock_repo = SqlAlchemyStockRegistryRepository(session)
        links_repo = SqlAlchemyStockSourceLinksRepository(session)
        stmt = select(MagentoStockRegistry).where(
            MagentoStockRegistry.connection_id == connection_id,
        ).order_by(MagentoStockRegistry.stock_id)
        rows = session.execute(stmt).scalars().all()
        stocks = []
        for r in rows:
            sources = links_repo.get_sources_for_stock(connection_id, r.stock_id)
            stocks.append({
                "stock_id": r.stock_id,
                "name": r.name,
                "sales_channels": r.sales_channels_json or [],
                "source_codes": sources,
            })
        return {"connection_id": connection_id, "stocks": stocks}


@app.post("/api/magento/connections/{connection_id}/msi/source-backfill")
async def magento_msi_source_backfill(
    connection_id: int,
    stock_id: Optional[int] = Query(None, description="Limit to sources linked to this stock"),
    dry_run: bool = Query(False, description="Only report what would be done"),
):
    """Backfill MSI source items for SKUs with no sources. Assigns qty=1 to each source."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.magento_msi_source_backfill import run_msi_source_backfill
    result = run_msi_source_backfill(
        connection_id,
        stock_id=stock_id,
        dry_run=dry_run,
    )
    if result.get("status") == "failed":
        raise HTTPException(status_code=500, detail=result.get("error", "MSI backfill failed"))
    return result


@app.post("/api/magento/connections/{connection_id}/enable-inventory-backfill")
async def magento_enable_inventory_backfill(
    connection_id: int,
    stock_id: Optional[int] = Query(None, description="Limit MSI sources to this stock's links"),
    dry_run: bool = Query(False, description="Only report what would be done"),
):
    """
    Add MSI sources + qty to simple/children products, and enable all products on website.

    1. Assigns source-items with default qty to simple products (including configurable children)
       that have no MSI sources.
    2. Sets status=1 (enabled) and appropriate visibility for all products:
       - Configurable children: visibility=1 (Not Visible Individually; shown as options on parent)
       - Simple and configurable parents: visibility=4 (Catalog + Search)
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from app.jobs.magento_enable_inventory_backfill import run_enable_inventory_backfill
    result = run_enable_inventory_backfill(
        connection_id,
        stock_id=stock_id,
        dry_run=dry_run,
    )
    if result.get("status") == "failed":
        raise HTTPException(status_code=500, detail=result.get("error", "Enable-inventory backfill failed"))
    return result


# --- SKU Deletion Review ---


@app.get("/api/magento/deletion-review")
async def list_deletion_review(connection_id: int, status: str = "pending"):
    """List SKUs flagged for deletion review for a connection."""
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")
    from db.models import MagentoDeletionReview
    from sqlalchemy import select as _select
    with get_session() as session:
        stmt = (
            _select(MagentoDeletionReview)
            .where(
                MagentoDeletionReview.connection_id == connection_id,
                MagentoDeletionReview.status == status,
            )
            .order_by(MagentoDeletionReview.flagged_at.desc())
        )
        rows = session.execute(stmt).scalars().all()
        return [
            {
                "id": r.id,
                "sku": r.sku,
                "product_type": r.product_type,
                "parent_sku": r.parent_sku,
                "ingest_run_id": r.ingest_run_id,
                "flagged_at": r.flagged_at.isoformat() if r.flagged_at else None,
                "status": r.status,
                "decided_at": r.decided_at.isoformat() if r.decided_at else None,
                "executed_at": r.executed_at.isoformat() if r.executed_at else None,
                "execution_result": r.execution_result,
            }
            for r in rows
        ]


@app.post("/api/magento/deletion-review/decide")
async def decide_deletion_review(body: dict = Body(...)):
    """
    Approve or reject pending deletion reviews.

    Body: {
      "connection_id": int,
      "approve": ["SKU1", ...],
      "reject": ["SKU2", ...],
      "magento_action": "hard" | "disable"  // required when approve is non-empty
    }

    Rejected SKUs are marked rejected so normal sync resumes for them.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    connection_id: int = body.get("connection_id")
    approve: List[str] = body.get("approve") or []
    reject: List[str] = body.get("reject") or []
    magento_action = body.get("magento_action")

    if connection_id is None:
        raise HTTPException(status_code=400, detail="connection_id is required")
    if approve and not magento_action:
        raise HTTPException(
            status_code=400,
            detail="magento_action is required when approving deletions ('hard' or 'disable')",
        )

    from db.models import MagentoDeletionReview, MagentoConnection
    from magento.magento_api import MagentoRestClient
    from magento.oauth_client import MagentoOAuthClient, build_magento_oauth_kwargs
    from magento.deletion_service import execute_deletion, normalize_magento_removal_action
    from sqlalchemy import select as _select

    removal_action = None
    if approve:
        removal_action = normalize_magento_removal_action(magento_action)

    with get_session() as session:
        conn_row = session.get(MagentoConnection, connection_id)
        if not conn_row:
            raise HTTPException(status_code=404, detail="Connection not found")

        results: dict = {"approved": [], "rejected": [], "not_found": []}
        now = datetime.now(timezone.utc)

        all_skus = list(set(list(approve) + list(reject)))
        if not all_skus:
            return results

        stmt = (
            _select(MagentoDeletionReview)
            .where(
                MagentoDeletionReview.connection_id == connection_id,
                MagentoDeletionReview.sku.in_(all_skus),
                MagentoDeletionReview.status == "pending",
            )
        )
        rows_by_sku = {r.sku: r for r in session.execute(stmt).scalars().all()}

        # Handle rejections
        for sku in reject:
            row = rows_by_sku.get(sku)
            if not row:
                results["not_found"].append(sku)
                continue
            row.status = "rejected"
            row.decided_at = now
            results["rejected"].append(sku)

        # Handle approvals — build API client once
        if approve:
            _base_path = conn_row.rest_base_path or "V1"
            _rest_base_path = f"{conn_row.store_code}/{_base_path}" if conn_row.store_code else _base_path
            oauth = MagentoOAuthClient(
                **build_magento_oauth_kwargs(conn_row, rest_base_path_override=_rest_base_path)
            )
            api = MagentoRestClient(oauth)

            for sku in approve:
                row = rows_by_sku.get(sku)
                if not row:
                    results["not_found"].append(sku)
                    continue
                row.status = "approved"
                row.decided_at = now
                execution_result = execute_deletion(
                    api=api,
                    sku=sku,
                    product_type=row.product_type,
                    parent_sku=row.parent_sku,
                    action=removal_action,
                )
                row.executed_at = datetime.now(timezone.utc)
                row.execution_result = execution_result
                results["approved"].append({"sku": sku, "result": execution_result})

        session.commit()
    return results


# Long-wait retry schedule when Magento is detected as down.
# After all immediate retries in oauth_client are exhausted, the background
# task waits these intervals (in seconds) before trying again.
_SERVER_DOWN_RETRY_WAITS = [
    1 * 3600,   # wait 1hr,  then 3 more immediate tries
    6 * 3600,   # wait 6hr,  then 3 more immediate tries
    12 * 3600,  # wait 12hr, then 3 more immediate tries
]
# How many consecutive server-down SKU failures before we declare Magento down
_SERVER_DOWN_THRESHOLD = 3


def _is_server_down_status(status: int) -> bool:
    return status == 0 or status >= 500


def _wait_for_server_recovery(api, logger, job_id: int) -> bool:
    """
    Called when Magento is detected as down. Waits per _SERVER_DOWN_RETRY_WAITS
    schedule, probing with verify_connection() after each wait.
    Returns True if server recovered, False if all waits exhausted.
    """
    for wait_secs in _SERVER_DOWN_RETRY_WAITS:
        hours = wait_secs / 3600
        label = f"{int(hours)}h" if hours == int(hours) else f"{hours}h"
        logger.warning(
            "[purge-and-readd] job=%d Magento appears down — waiting %s before next retry group",
            job_id, label,
        )
        time.sleep(wait_secs)
        if api.verify_connection():
            logger.info("[purge-and-readd] job=%d Magento recovered after %s wait", job_id, label)
            return True
        logger.warning("[purge-and-readd] job=%d Magento still down after %s wait", job_id, label)
    logger.error("[purge-and-readd] job=%d Magento unreachable after all retry windows — aborting", job_id)
    return False


def _purge_and_readd_background(
    connection_id: int,
    sku_list: list,
    conn: dict,
    job_id: int,
):
    """Background task: delete products from Magento, reset state, re-sync."""
    import logging
    logger = logging.getLogger(__name__)

    cfg = load_magento_config()
    registry_codes = load_attribute_registry_codes(load_db_config().attribute_registry_path)
    registry_options = load_attribute_registry_options(load_db_config().attribute_registry_path)

    oauth = MagentoOAuthClient(**build_magento_oauth_kwargs(conn))
    api = MagentoRestClient(oauth)

    try:
        # ── Step 1: Delete products from Magento ──────────────────────────────
        logger.info("[purge-and-readd] job=%d deleting %d SKUs from Magento", job_id, len(sku_list))
        delete_results: dict = {"deleted": [], "not_found": [], "errors": []}
        consecutive_down = 0
        i = 0
        aborted = False
        while i < len(sku_list):
            sku = sku_list[i]
            try:
                status_code, err = api.delete_product(sku)
            except Exception as exc:
                status_code, err = 0, str(exc)

            if status_code in (200, 204):
                delete_results["deleted"].append(sku)
                consecutive_down = 0
                i += 1
            elif status_code == 404:
                delete_results["not_found"].append(sku)
                consecutive_down = 0
                i += 1
            elif _is_server_down_status(status_code):
                consecutive_down += 1
                logger.warning(
                    "[purge-and-readd] job=%d server-down response for SKU %s (consecutive=%d)",
                    job_id, sku, consecutive_down,
                )
                if consecutive_down >= _SERVER_DOWN_THRESHOLD:
                    recovered = _wait_for_server_recovery(api, logger, job_id)
                    if not recovered:
                        for remaining in sku_list[i:]:
                            delete_results["errors"].append({"sku": remaining, "error": "Magento server unreachable"})
                        aborted = True
                        break
                    consecutive_down = 0
                    # Retry same SKU (don't increment i)
                else:
                    # Not enough consecutive failures yet — record and move on
                    delete_results["errors"].append({"sku": sku, "error": err or f"HTTP {status_code}"})
                    i += 1
            else:
                delete_results["errors"].append({"sku": sku, "error": err or f"HTTP {status_code}"})
                consecutive_down = 0
                i += 1

        logger.info(
            "[purge-and-readd] job=%d deleted=%d not_found=%d errors=%d aborted=%s",
            job_id, len(delete_results["deleted"]), len(delete_results["not_found"]),
            len(delete_results["errors"]), aborted,
        )
        if aborted:
            with get_session() as session:
                sync_repo = SqlAlchemyMagentoSyncRepository(session)
                sync_repo.update_sync_job(job_id, status="failed", finished_at=datetime.utcnow())
                session.commit()
            return

        # ── Step 2: Reset sync state hashes ───────────────────────────────────
        with get_session() as session:
            sync_repo = SqlAlchemyMagentoSyncRepository(session)
            state_repo = SqlAlchemyMagentoSyncStateRepository(session)
            catalog_repo = SqlAlchemyMagentoCatalogStateRepository(session)
            media_repo = SqlAlchemyMagentoMediaMapRepository(session)
            override_repo = SqlAlchemyMagentoProductOverrideRepository(session)
            version_repo = SqlAlchemyMagentoProductVersionRepository(session)
            notification_repo = SqlAlchemyMagentoSyncNotificationRepository(session)
            ingest_repo = SqlAlchemyIngestRepository(session)
            attr_registry_repo = SqlAlchemyAttributeRegistryRepository(session)
            attr_set_attrs_repo = SqlAlchemyAttributeSetAttributesRepository(session)
            attr_set_registry_repo = SqlAlchemyAttributeSetRegistryRepository(session)
            cat_registry_repo = SqlAlchemyCategoryRegistryRepository(session)
            baseline_repo = SqlAlchemyBaselineSyncRunRepository(session)
            store_registry_repo = SqlAlchemyMagentoStoreRegistryRepository(session)
            source_repo = SqlAlchemySourceRegistryRepository(session)
            pending_repo = SqlAlchemyMagentoSyncPendingRepository(session)

            state_repo.reset_hashes(connection_id, skus=sku_list)
            catalog_repo.reset_magento_ids(connection_id, skus=sku_list)
            session.flush()
            logger.info(f"[purge-and-readd] job={job_id} state/catalog hashes reset")

            # ── Step 3: Re-add via incremental sync ───────────────────────────
            def _source_codes_provider(cid: int):
                codes = source_repo.get_enabled_source_codes(cid)
                if not codes:
                    codes = source_repo.get_all_source_codes(cid)
                if not codes:
                    configured = os.getenv("MAGENTO_DEFAULT_SOURCE_CODE", "").strip()
                    if configured:
                        return [configured]
                return codes or ["default"]

            snapshots = ingest_repo.load_current_snapshots(sku_list)
            prices = ingest_repo.load_current_prices_for_skus(sku_list)
            overrides = ingest_repo.load_overrides_for_skus(sku_list)

            snapshot_df = build_dataframe_from_snapshots(snapshots)
            for idx, row in snapshot_df.iterrows():
                sku = str(row.get("sku", "")).strip()
                if sku and sku in prices:
                    p = prices[sku]
                    snapshot_df.at[idx, "price"] = p.get("price")
                    snapshot_df.at[idx, "qty"] = p.get("qty")
                    snapshot_df.at[idx, "is_in_stock"] = p.get("is_in_stock")
            snapshot_df = apply_overrides(snapshot_df, overrides)
            normalized_df, _ = normalize_plytix_df(
                snapshot_df,
                cfg,
                allowed_attribute_codes=registry_codes or None,
                allowed_attribute_options=registry_options or None,
                enforce_required_name=False,
            )
            snapshot_rows = normalized_df.to_dict(orient="records")

            sync_repo.update_sync_job(job_id, total_count=len(snapshot_rows))

            sync_svc = MagentoSyncService(
                api,
                conn,
                sync_state_repo=state_repo,
                media_map_repo=media_repo,
                sync_job_repo=sync_repo,
                override_repo=override_repo,
                version_repo=version_repo,
                notification_repo=notification_repo,
                attr_cache_repo=attr_registry_repo,
                category_cache_repo=cat_registry_repo,
                store_registry_repo=store_registry_repo,
                baseline_repo=baseline_repo,
                attr_set_attrs_repo=attr_set_attrs_repo,
                attr_set_registry_repo=attr_set_registry_repo,
                resolve_image_url=lambda u: u,
                source_codes_provider=_source_codes_provider,
                sync_pending_repo=pending_repo,
            )

            catalog_media = catalog_repo.get_media_hashes_for_skus(connection_id, sku_list)
            catalog_state = catalog_repo.get_catalog_state_for_skus(connection_id, sku_list)
            logger.info(f"[purge-and-readd] job={job_id} starting incremental sync for {len(snapshot_rows)} rows")
            sync_svc.sync_incremental(
                connection_id,
                snapshot_rows,
                job_id=job_id,
                dry_run=False,
                state_repo=state_repo,
                job_repo=sync_repo,
                override_repo=override_repo,
                version_repo=version_repo,
                notification_repo=notification_repo,
                catalog_state_media_by_sku=catalog_media,
                catalog_state_by_sku=catalog_state,
                force_full_sync=True,
            )
            session.commit()
            logger.info(f"[purge-and-readd] job={job_id} completed")

    except Exception as exc:
        logger.exception(f"[purge-and-readd] job={job_id} failed: {exc}")
        try:
            with get_session() as session:
                sync_repo = SqlAlchemyMagentoSyncRepository(session)
                sync_repo.update_sync_job(job_id, status="failed", finished_at=datetime.utcnow())
                session.commit()
        except Exception:
            pass


@app.post("/api/magento/connections/{connection_id}/purge-and-readd")
async def magento_purge_and_readd(
    background_tasks: BackgroundTasks,
    connection_id: int,
    skus: Optional[List[str]] = Body(None, embed=True),
    dry_run: bool = Body(False, embed=True),
):
    """
    Purge products from Magento and re-add them via a full incremental sync.

    Steps performed:
    1. Delete the specified SKUs (or all current SKUs) from Magento via the REST API.
    2. Reset magento_sync_state hashes → forces all three passes (product, media, relations)
       on the next sync.
    3. Reset magento_catalog_state Magento IDs and media state → prices and special_prices
       are preserved so they are re-pushed correctly on re-create.
    4. Run incremental sync with force_full_sync=True to re-create all products immediately.

    Tables intentionally left untouched:
    - magento_media_map (image file paths on Magento server reused during re-upload)
    - magento_product_override (force/lock overrides preserved)
    - product_snapshot / sku_price_snapshot (Plytix feed data unchanged)

    Returns immediately with a job_id. The purge + sync runs in the background.
    """
    db_cfg = load_db_config()
    if not db_cfg.enabled:
        raise HTTPException(status_code=400, detail="DB is not enabled")

    with get_session() as session:
        conn_repo = SqlAlchemyMagentoConnectionRepository(session)
        sync_repo = SqlAlchemyMagentoSyncRepository(session)
        ingest_repo = SqlAlchemyIngestRepository(session)

        conn = conn_repo.get_for_sync(connection_id)
        if not conn:
            raise HTTPException(status_code=404, detail="Connection not found")

        # Determine the full SKU set (expand configurable children too)
        requested_skus = list(skus) if skus else None
        sku_list = requested_skus if requested_skus else ingest_repo.load_all_current_skus()
        if not sku_list:
            return {"message": "No products found to purge", "deleted": 0, "job_id": None}
        sku_list = expand_sku_list_with_children(ingest_repo, sku_list)

        if dry_run:
            return {
                "dry_run": True,
                "would_purge": len(sku_list),
                "skus": sku_list,
            }

        # Create the job record now so we can return the job_id immediately
        job_id = sync_repo.create_sync_job(
            MagentoSyncJobCreate(
                connection_id=connection_id,
                dry_run=False,
                total_count=len(sku_list),
            )
        )
        sync_repo.update_sync_job(job_id, status="running", started_at=datetime.utcnow())
        session.commit()

    background_tasks.add_task(
        _purge_and_readd_background,
        connection_id=connection_id,
        sku_list=sku_list,
        conn=dict(conn),
        job_id=job_id,
    )

    return {
        "job_id": job_id,
        "sku_count": len(sku_list),
        "message": f"Purge and re-sync started in background for {len(sku_list)} SKUs. Poll /api/magento/jobs/{job_id} for status.",
    }


if __name__ == "__main__":
    import uvicorn

    port = int(os.getenv("PORT", "39810"))
    host = os.getenv("UVICORN_HOST", "127.0.0.1")
    # Production: UVICORN_WORKERS=2+ with dedicated systemd job workers.
    # Dev: UVICORN_RELOAD=true (forces a single process; incompatible with workers>1).
    reload_enabled = os.getenv("UVICORN_RELOAD", "false").strip().lower() in {
        "1",
        "true",
        "yes",
        "on",
    }
    workers = max(1, int(os.getenv("UVICORN_WORKERS", "1")))
    if reload_enabled and workers > 1:
        workers = 1
    uvicorn.run(
        "main:app",
        host=host,
        port=port,
        workers=1 if reload_enabled else workers,
        reload=reload_enabled,
        proxy_headers=True,
        forwarded_allow_ips="*",
    )
