"""Audit (and optionally remove) uncurated shopping_* options on Magento sites.

Option seeding used to create a Magento select option for every distinct master
value of shopping_collection / shopping_l1 / shopping_l2, so hub/L1 slugs derived
from PLP paths (kitchen-cabinets/mouldings, panels-and-fillers, accessories) became
storefront facets that no operator ever curated.

Read-only by default. With --delete, each affected product is reassigned to its
curated value first (or cleared when there is none), and only then is the option
removed from Magento and from the local option registry.

    python -m app.jobs.audit_shopping_taxonomy_options
    python -m app.jobs.audit_shopping_taxonomy_options --connection-id 1
    python -m app.jobs.audit_shopping_taxonomy_options --connection-id 1 --delete
"""

from __future__ import annotations

import argparse
import json
import logging
import re
from typing import Any, Dict, List, Optional, Protocol, Sequence

from sqlalchemy import delete as sa_delete, select
from sqlalchemy.orm import Session

from db.models import MagentoAttributeOptionRegistry, MagentoConnection
from db.session import get_session
from channel.attribute_value_resolver import norm_option_label, resolve_select_value_index
from db.shopping_taxonomy_vocabulary import (
    SHOPPING_TAXONOMY_CODES,
    ShoppingTaxonomyVocabulary,
    load_shopping_taxonomy_vocabulary,
)

logger = logging.getLogger(__name__)

PRODUCT_PAGE_SIZE = 100
MAX_PRODUCT_PAGES = 200
# Magento select attributes use an int backend: option id 0 means "no option selected".
# Sending "" is rejected with 'The "" value's type is invalid. The "int" type was expected.'
CLEAR_SELECT_VALUE = "0"


class MagentoOptionPort(Protocol):
    """Outbound port so the audit can be exercised without a live Magento."""

    def get_attribute_options(self, attribute_code: str) -> Any: ...

    def delete_attribute_option(self, attribute_code: str, option_id: str) -> Any: ...

    def search_products_by_attribute(
        self, attribute_code: str, value: str, *, page_size: int = ..., current_page: int = ...
    ) -> Any: ...

    def update_product(self, sku: str, payload: Dict[str, Any]) -> Any: ...


def audit_connection(
    session: Session,
    *,
    connection_id: int,
    api: MagentoOptionPort,
    vocabulary: Optional[ShoppingTaxonomyVocabulary] = None,
    delete: bool = False,
    dry_run: bool = False,
    attribute_codes: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
    """Report uncurated shopping_* options; optionally plan (dry_run) or apply (delete) the cleanup."""
    vocab = vocabulary if vocabulary is not None else load_shopping_taxonomy_vocabulary(session)
    codes = [str(code).strip() for code in (attribute_codes or SHOPPING_TAXONOMY_CODES) if str(code).strip()]
    result: Dict[str, Any] = {
        "connection_id": connection_id,
        "delete": delete,
        "dry_run": dry_run,
        "vocabulary_empty": vocab.is_empty,
        "attributes": [],
        "errors": [],
    }
    if vocab.is_empty:
        result["errors"].append(
            "No curated shopping taxonomy found (manual taxonomy assignments and Start Shopping "
            "configs are both empty); refusing to classify options as uncurated."
        )
        return result

    for code in codes:
        entry: Dict[str, Any] = {
            "attribute_code": code,
            "curated_values": vocab.curated_values(code),
            "curated_but_no_active_taxonomy_node": vocab.unbacked_values(code),
            "uncurated_options": [],
            "deleted_options": [],
            "reassigned_products": [],
            "cleared_products": [],
            "skipped_options": [],
            "planned_reassignments": [],
            "planned_clears": [],
        }
        result["attributes"].append(entry)

        status, options = api.get_attribute_options(code)
        if status != 200:
            result["errors"].append(f"{code}: could not read options (HTTP {status})")
            continue

        # shopping_* are select attributes with an int backend, so products carry the
        # option id — never the slug label. Resolve through the same map the sync path
        # uses so the audit reassigns to exactly what a publish would write.
        option_map = _option_map(options)

        for option in options or []:
            if not isinstance(option, dict):
                continue
            label = str(option.get("label") or "").strip()
            value_index = str(option.get("value") or "").strip()
            if not label or not value_index:
                continue
            if vocab.allows(code, label):
                continue
            affected = _affected_skus(api, code, value_index, result["errors"])
            entry["uncurated_options"].append(
                {"label": label, "value_index": value_index, "affected_skus": affected}
            )
            if not (delete or dry_run):
                continue

            replacements = _curated_replacements(session, connection_id=connection_id, skus=affected, code=code)
            resolved = {
                target: resolve_select_value_index(target, option_map, code)
                for target in {replacements.get(sku) for sku in affected}
                if target
            }
            # A target that resolves back to the option under deletion would strand its products.
            missing_targets = sorted(
                target for target, vi in resolved.items() if vi is None or str(vi) == value_index
            )
            if missing_targets:
                # Reassigning would need an option the freeze has not provisioned; leaving
                # the products where they are beats stranding them on a deleted option.
                entry["skipped_options"].append(
                    {"label": label, "reason": "target_option_missing", "missing_targets": missing_targets}
                )
                continue

            writes = [
                (sku, replacements.get(sku) or "", str(resolved[replacements[sku]]) if replacements.get(sku) else CLEAR_SELECT_VALUE)
                for sku in affected
            ]
            if dry_run:
                for sku, target, value in writes:
                    if target:
                        entry["planned_reassignments"].append({"sku": sku, "value": target, "option_id": value})
                    else:
                        entry["planned_clears"].append(sku)
                continue

            failed_skus: List[str] = []
            for sku, target, value in writes:
                if not _write_product_attribute(api, sku, code, value, result["errors"]):
                    failed_skus.append(sku)
                elif target:
                    entry["reassigned_products"].append({"sku": sku, "value": target, "option_id": value})
                else:
                    entry["cleared_products"].append(sku)

            if failed_skus:
                entry["skipped_options"].append(
                    {"label": label, "reason": "product_update_failed", "failed_skus": failed_skus}
                )
                continue

            del_status, _, del_err = api.delete_attribute_option(code, value_index)
            if del_status not in (200, 201, 204):
                result["errors"].append(
                    f"{code}: delete option '{label}' failed (HTTP {del_status} {del_err or ''})".strip()
                )
                continue
            entry["deleted_options"].append(label)
            _forget_option(session, connection_id=connection_id, attribute_code=code, option_label=label)

    return result


def _option_map(options: Any) -> Dict[str, int]:
    """Normalized option label → Magento value_index, as the publish path expects it."""
    out: Dict[str, int] = {}
    for option in options or []:
        if not isinstance(option, dict):
            continue
        label = norm_option_label(str(option.get("label") or ""))
        raw = option.get("value")
        if raw is None:
            raw = option.get("value_index")
        try:
            value_index = int(str(raw).strip())
        except (TypeError, ValueError):
            continue
        if label:
            out[label] = value_index
    return out


def _affected_skus(
    api: MagentoOptionPort,
    attribute_code: str,
    value_index: str,
    errors: List[str],
) -> List[str]:
    skus: List[str] = []
    for page in range(1, MAX_PRODUCT_PAGES + 1):
        status, items, _total, err = api.search_products_by_attribute(
            attribute_code, value_index, page_size=PRODUCT_PAGE_SIZE, current_page=page
        )
        if status != 200:
            errors.append(f"{attribute_code}: product search failed (HTTP {status} {err or ''})".strip())
            break
        for item in items or []:
            sku = str((item or {}).get("sku") or "").strip()
            if sku:
                skus.append(sku)
        if len(items or []) < PRODUCT_PAGE_SIZE:
            break
    return skus


def _curated_replacements(
    session: Session,
    *,
    connection_id: int,
    skus: Sequence[str],
    code: str,
) -> Dict[str, str]:
    """Curated value each SKU should carry, from the (now curation-gated) channel projection."""
    if not skus:
        return {}
    try:
        from db.channel_exports import build_channel_product_payloads

        payloads = build_channel_product_payloads(
            session,
            "magento",
            skus=list(skus),
            only_assigned=False,
            connection_id=connection_id,
            # The projection cache may still hold pre-fix payloads with the bad value.
            prefer_projection_cache=False,
        )
    except Exception as exc:
        logger.warning("Could not rebuild payloads for reassignment: %s", exc)
        return {}
    out: Dict[str, str] = {}
    for payload in payloads:
        sku = str(payload.get("sku") or "").strip()
        value = str((payload.get("fields") or {}).get(code) or "").strip()
        if sku and value:
            out[sku] = value
    return out


def _write_product_attribute(
    api: MagentoOptionPort,
    sku: str,
    attribute_code: str,
    value: str,
    errors: List[str],
) -> bool:
    try:
        api.update_product(
            sku,
            {"sku": sku, "custom_attributes": [{"attribute_code": attribute_code, "value": value}]},
        )
        return True
    except Exception as exc:
        errors.append(f"{attribute_code}: could not update {sku}: {exc}")
        return False


def _forget_option(
    session: Session,
    *,
    connection_id: int,
    attribute_code: str,
    option_label: str,
) -> None:
    # Must match the normalization _record_magento_option writes.
    label_norm = re.sub(r"\s+", " ", str(option_label or "").strip().lower())
    session.execute(
        sa_delete(MagentoAttributeOptionRegistry)
        .where(MagentoAttributeOptionRegistry.connection_id == connection_id)
        .where(MagentoAttributeOptionRegistry.attribute_code == attribute_code)
        .where(MagentoAttributeOptionRegistry.option_label_norm == label_norm)
    )


def run(
    *,
    connection_id: Optional[int] = None,
    delete: bool = False,
    dry_run: bool = False,
    attribute_codes: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
    from db.magento_repositories import SqlAlchemyMagentoConnectionRepository
    from magento.magento_api import MagentoOAuthClient, MagentoRestClient
    from magento.oauth_client import build_magento_oauth_kwargs

    with get_session() as session:
        if connection_id is not None:
            connection_ids = [connection_id]
        else:
            connection_ids = [
                int(row) for row in session.scalars(select(MagentoConnection.id).order_by(MagentoConnection.id)).all()
            ]
        if not connection_ids:
            return {"connections": [], "errors": ["No Magento connections configured"]}

        repo = SqlAlchemyMagentoConnectionRepository(session)
        vocabulary = load_shopping_taxonomy_vocabulary(session)
        out: Dict[str, Any] = {"delete": delete, "dry_run": dry_run, "connections": [], "errors": []}
        for native_id in connection_ids:
            conn = repo.get_for_sync(native_id)
            if not conn:
                out["errors"].append(f"Magento connection {native_id} not found")
                continue
            api = MagentoRestClient(MagentoOAuthClient(**build_magento_oauth_kwargs(conn)))
            out["connections"].append(
                audit_connection(
                    session,
                    connection_id=native_id,
                    api=api,
                    vocabulary=vocabulary,
                    delete=delete,
                    dry_run=dry_run,
                    attribute_codes=attribute_codes,
                )
            )
            if delete and not dry_run:
                session.commit()
        return out


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Audit uncurated shopping_* Magento attribute options"
    )
    parser.add_argument("--connection-id", type=int, default=None, help="Default: every Magento connection")
    parser.add_argument(
        "--delete",
        action="store_true",
        help="Reassign affected products, then delete the uncurated options",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Plan the reassignments/clears and report blockers without writing to Magento",
    )
    parser.add_argument(
        "--attribute-code",
        action="append",
        dest="attribute_codes",
        help=f"Limit to specific codes (default: {', '.join(SHOPPING_TAXONOMY_CODES)})",
    )
    args = parser.parse_args()
    print(
        json.dumps(
            run(
                connection_id=args.connection_id,
                delete=args.delete,
                dry_run=args.dry_run,
                attribute_codes=args.attribute_codes,
            ),
            indent=2,
        )
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
