import os
import json
from dataclasses import dataclass
from typing import Dict, List, Optional

from dotenv import load_dotenv

load_dotenv()

def _csv_list(val: Optional[str]) -> List[str]:
    if not val:
        return []
    return [x.strip() for x in val.split(",") if x.strip()]


def _dedupe_preserve_order(items: List[str]) -> List[str]:
    seen = set()
    out: List[str] = []
    for item in items:
        key = item.strip().lower()
        if not key or key in seen:
            continue
        seen.add(key)
        out.append(item)
    return out


def _json_dict(val: Optional[str]) -> Dict[str, str]:
    if not val:
        return {}
    try:
        data = json.loads(val)
        if not isinstance(data, dict):
            raise ValueError("JSON must be an object/dict")
        # force str:str
        return {str(k): str(v) for k, v in data.items()}
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in env var: {e}")


def _bool(val: Optional[str], default: bool = False) -> bool:
    if val is None:
        return default
    return val.strip().lower() in {"1", "true", "yes", "y", "on"}


@dataclass(frozen=True)
class MagentoNormalizeConfig:
    keep_cols: List[str]
    seed_children_col: str
    axis_col: str
    labels_col: str
    additional_col: str
    also_pack_cols: List[str]
    drop_before_pack: List[str]
    drop_after_compute: List[str]
    hardcoded_additional: Dict[str, str]
    default_attribute_set: str
    empty_to_default_attribute_set: bool


@dataclass(frozen=True)
class DatabaseConfig:
    enabled: bool
    output_suffix: str
    attribute_registry_path: str


def load_db_config() -> DatabaseConfig:
    return DatabaseConfig(
        enabled=_bool(os.getenv("PLYTIX_DB_ENABLED"), False),
        output_suffix=os.getenv("PLYTIX_DB_OUTPUT_SUFFIX", "_db").strip() or "_db",
        attribute_registry_path=os.getenv(
            "PLYTIX_ATTRIBUTE_REGISTRY_PATH",
            os.path.join("data", "attribute_registry.json"),
        ),
    )


def shorten_image_urls_enabled() -> bool:
    """Whether to shorten image URLs in normalized output. Default False (disabled)."""
    return _bool(os.getenv("SHORTEN_IMAGE_URLS_ENABLED"), False)


@dataclass(frozen=True)
class ShopifyConfig:
    shop_domain: str
    admin_access_token: str
    client_id: str
    client_secret: str
    api_version: str
    shop_code: str
    product_options_sample_size: int


def load_shopify_config() -> ShopifyConfig:
    shop_domain = os.getenv("SHOPIFY_SHOP_DOMAIN", "").strip()
    shop_code = os.getenv("SHOPIFY_SHOP_CODE", "").strip() or shop_domain
    sample_size_raw = os.getenv("SHOPIFY_PRODUCT_OPTIONS_SAMPLE_SIZE", "250").strip()
    sample_size = int(sample_size_raw) if sample_size_raw.isdigit() else 250
    return ShopifyConfig(
        shop_domain=shop_domain,
        admin_access_token=os.getenv("SHOPIFY_ADMIN_ACCESS_TOKEN", "").strip(),
        client_id=os.getenv("SHOPIFY_CLIENT_ID", "").strip(),
        client_secret=os.getenv("SHOPIFY_CLIENT_SECRET", "").strip(),
        api_version=os.getenv("SHOPIFY_API_VERSION", "2026-04").strip() or "2026-04",
        shop_code=shop_code,
        product_options_sample_size=max(0, sample_size),
    )


@dataclass(frozen=True)
class PlytixApiConfig:
    base_url: str
    api_key: str
    api_password: str
    api_token: str
    products_path: str
    attributes_path: str
    page_size: int
    max_pages: int


def load_plytix_api_config() -> PlytixApiConfig:
    page_size_raw = os.getenv("PLYTIX_API_PAGE_SIZE", "100").strip()
    max_pages_raw = os.getenv("PLYTIX_API_MAX_PAGES", "100").strip()
    return PlytixApiConfig(
        base_url=os.getenv("PLYTIX_API_BASE_URL", "https://pim.plytix.com/api/v1").strip().rstrip("/"),
        api_key=os.getenv("PLYTIX_API_KEY", "").strip(),
        api_password=os.getenv("PLYTIX_API_PASSWORD", "").strip(),
        api_token=os.getenv("PLYTIX_API_TOKEN", "").strip(),
        products_path=os.getenv("PLYTIX_PRODUCTS_PATH", "products/search").strip() or "products/search",
        attributes_path=os.getenv("PLYTIX_ATTRIBUTES_PATH", "attributes/product/search").strip()
        or "attributes/product/search",
        page_size=int(page_size_raw) if page_size_raw.isdigit() else 100,
        max_pages=int(max_pages_raw) if max_pages_raw.isdigit() else 100,
    )


database_url = os.getenv("DATABASE_URL", "").strip()


def get_database_url() -> str:
    if not database_url:
        raise RuntimeError("DATABASE_URL is required when DB is enabled")
    return database_url


def load_attribute_registry_codes(path: str) -> set[str]:
    if not path:
        return set()
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
    except Exception:
        return set()
    if not isinstance(data, dict):
        return set()
    codes: set[str] = set()
    for key in data.keys():
        text = str(key).strip()
        if not text:
            continue
        if text.lower().startswith("if you add"):
            continue
        codes.add(text)
    return codes


def load_attribute_registry_options(path: str) -> Dict[str, List[str]]:
    if not path:
        return {}
    try:
        with open(path, "r", encoding="utf-8") as handle:
            data = json.load(handle)
    except Exception:
        return {}
    if not isinstance(data, dict):
        return {}
    options: Dict[str, List[str]] = {}
    for key, meta in data.items():
        code = str(key).strip()
        if not code:
            continue
        if code.lower().startswith("if you add"):
            continue
        if not isinstance(meta, dict):
            continue
        raw = meta.get("options")
        if not isinstance(raw, list):
            continue
        cleaned: List[str] = []
        for item in raw:
            text = str(item).strip()
            if not text:
                continue
            if text.lower() == "see attribute options column":
                continue
            cleaned.append(text)
        if cleaned:
            options[code] = cleaned
    return options


@dataclass(frozen=True)
class MagentoPullConfig:
    enabled: bool
    hour_utc: int
    lookback_days: int
    connection_ids: List[int]


def load_magento_pull_config() -> MagentoPullConfig:
    return MagentoPullConfig(
        enabled=_bool(os.getenv("MAGENTO_PULL_ENABLED"), False),
        hour_utc=int(os.getenv("MAGENTO_PULL_HOUR_UTC", "2") or "2"),
        lookback_days=int(os.getenv("MAGENTO_PULL_LOOKBACK_DAYS", "3") or "3"),
        connection_ids=[
            int(x.strip()) for x in (os.getenv("MAGENTO_PULL_CONNECTION_IDS") or "").split(",")
            if x.strip() and x.strip().isdigit()
        ],
    )


@dataclass(frozen=True)
class EmailConfig:
    provider: str
    from_address: str
    to_default: Optional[str]
    resend_api_key: Optional[str]
    mailgun_api_key: Optional[str]
    mailgun_domain: Optional[str]


def load_email_config() -> EmailConfig:
    return EmailConfig(
        provider=(os.getenv("EMAIL_PROVIDER") or "disabled").strip().lower(),
        from_address=os.getenv("EMAIL_FROM", "").strip(),
        to_default=os.getenv("EMAIL_TO_DEFAULT", "").strip() or None,
        resend_api_key=os.getenv("RESEND_API_KEY", "").strip() or None,
        mailgun_api_key=os.getenv("MAILGUN_API_KEY", "").strip() or None,
        mailgun_domain=os.getenv("MAILGUN_DOMAIN", "").strip() or None,
    )


@dataclass(frozen=True)
class MagentoSyncTriggerConfig:
    """Legacy Plytix-feed triggers only. Master-catalog push schedules use channel_schedule (UI)."""
    sync_on_feed_upload: bool
    scheduled_sync_enabled: bool  # legacy in-process daily enqueue; prefer UI schedules
    scheduled_sync_time_utc: str  # "HH:MM" e.g. "11:00" for 0300 PST


def load_magento_sync_trigger_config() -> MagentoSyncTriggerConfig:
    return MagentoSyncTriggerConfig(
        sync_on_feed_upload=_bool(os.getenv("MAGENTO_SYNC_ON_FEED_UPLOAD"), False),
        scheduled_sync_enabled=_bool(os.getenv("MAGENTO_SCHEDULED_SYNC_ENABLED"), False),
        scheduled_sync_time_utc=os.getenv("MAGENTO_SCHEDULED_SYNC_TIME_UTC", "11:00").strip() or "11:00",
    )


@dataclass(frozen=True)
class ChannelCatalogProvisionTriggerConfig:
    enabled_on_master_upload: bool
    dry_run: bool
    only_assigned: bool
    include_magento: bool
    include_shopify: bool
    create_missing: bool
    assign_skus: bool
    delta_only: bool
    seed_master_listing_paths: bool
    provision_attributes: bool
    include_brand_collections: bool
    shopify_brand_filter: List[str]


def load_channel_catalog_provision_trigger_config() -> ChannelCatalogProvisionTriggerConfig:
    return ChannelCatalogProvisionTriggerConfig(
        enabled_on_master_upload=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_ON_MASTER_UPLOAD"), True),
        dry_run=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_DRY_RUN"), True),
        only_assigned=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_ONLY_ASSIGNED"), True),
        include_magento=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_INCLUDE_MAGENTO"), True),
        include_shopify=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_INCLUDE_SHOPIFY"), True),
        create_missing=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_CREATE_MISSING"), True),
        assign_skus=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_ASSIGN_SKUS"), True),
        delta_only=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_DELTA_ONLY"), True),
        seed_master_listing_paths=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_SEED_MASTER_LISTING_PATHS"), True),
        provision_attributes=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_ATTRIBUTES"), True),
        include_brand_collections=_bool(os.getenv("CHANNEL_CATALOG_PROVISION_INCLUDE_BRAND_COLLECTIONS"), True),
        shopify_brand_filter=_csv_list(os.getenv("CHANNEL_CATALOG_PROVISION_SHOPIFY_BRANDS", "Port & Bell")),
    )


@dataclass(frozen=True)
class MasterTaxonomyFreezeConfig:
    """When auto_invent is False (default), pipelines/jobs must not invent collections/nodes."""

    auto_invent: bool


def load_master_taxonomy_freeze_config() -> MasterTaxonomyFreezeConfig:
    return MasterTaxonomyFreezeConfig(
        auto_invent=_bool(os.getenv("MASTER_TAXONOMY_AUTO_INVENT"), False),
    )


@dataclass(frozen=True)
class MagentoSyncRuntimeConfig:
    api_delay_ms: int
    api_retry_max_attempts: int
    api_retry_base_ms: int


def load_magento_sync_runtime_config() -> MagentoSyncRuntimeConfig:
    return MagentoSyncRuntimeConfig(
        api_delay_ms=int(os.getenv("MAGENTO_API_DELAY_MS", "200") or "200"),
        api_retry_max_attempts=int(os.getenv("MAGENTO_API_RETRY_MAX_ATTEMPTS", "3") or "3"),
        api_retry_base_ms=int(os.getenv("MAGENTO_API_RETRY_BASE_MS", "500") or "500"),
    )


@dataclass(frozen=True)
class MagentoBulkProductConfig:
    """Async bulk UPSERT_PRODUCT writer (opt-in). Requires Magento queue consumers."""

    enabled: bool
    batch_size: int
    poll_interval_ms: int
    poll_timeout_s: int


def load_magento_bulk_product_config() -> MagentoBulkProductConfig:
    batch = int(os.getenv("MAGENTO_BULK_PRODUCT_BATCH_SIZE", "100") or "100")
    poll_ms = int(os.getenv("MAGENTO_BULK_PRODUCT_POLL_INTERVAL_MS", "2000") or "2000")
    timeout_s = int(os.getenv("MAGENTO_BULK_PRODUCT_POLL_TIMEOUT_S", "600") or "600")
    return MagentoBulkProductConfig(
        enabled=_bool(os.getenv("MAGENTO_BULK_PRODUCT_UPSERT"), False),
        batch_size=max(1, min(batch, 200)),
        poll_interval_ms=max(200, poll_ms),
        poll_timeout_s=max(30, timeout_s),
    )


@dataclass(frozen=True)
class MagentoAttrCacheConfig:
    ttl_days: int
    refresh_mode: str  # "on_demand" | "pre_sync" | "scheduled"


def load_magento_attr_cache_config() -> MagentoAttrCacheConfig:
    mode = (os.getenv("MAGENTO_ATTR_CACHE_REFRESH_MODE", "pre_sync") or "pre_sync").strip().lower()
    if mode not in ("on_demand", "pre_sync", "scheduled"):
        mode = "pre_sync"
    return MagentoAttrCacheConfig(
        ttl_days=int(os.getenv("MAGENTO_ATTR_CACHE_TTL_DAYS", "7") or "7"),
        refresh_mode=mode,
    )


@dataclass(frozen=True)
class MagentoCategoryCacheConfig:
    ttl_days: int
    auto_create: bool


def load_magento_category_cache_config() -> MagentoCategoryCacheConfig:
    return MagentoCategoryCacheConfig(
        ttl_days=int(os.getenv("MAGENTO_CATEGORY_CACHE_TTL_DAYS", "7") or "7"),
        auto_create=_bool(os.getenv("MAGENTO_CATEGORY_AUTO_CREATE"), True),
    )


@dataclass(frozen=True)
class MagentoBaselineConfig:
    ttl_hours: int
    configurable_attribute_codes: List[str]
    default_website_id: Optional[int]
    all_products_category_id: Optional[int]
    default_attribute_set_name: Optional[str]
    default_source_code: Optional[str]  # MSI source for inventory (e.g. "default", "homesurplus")
    default_msi_qty: float  # Default qty when missing (MSI "always in stock")


@dataclass(frozen=True)
class MagentoSyncAutoCreateConfig:
    """Feature flags for auto-creating attributes/options when Plytix introduces new ones."""

    auto_create_attributes: bool
    auto_create_options: bool
    reserved_attribute_codes: List[str]  # Denylist: never auto-create these


def load_magento_sync_auto_create_config() -> MagentoSyncAutoCreateConfig:
    return MagentoSyncAutoCreateConfig(
        auto_create_attributes=_bool(os.getenv("MAGENTO_AUTO_CREATE_ATTRIBUTES"), False),
        auto_create_options=_bool(os.getenv("MAGENTO_AUTO_CREATE_OPTIONS"), False),
        reserved_attribute_codes=[
            x.strip().lower()
            for x in (os.getenv("MAGENTO_RESERVED_ATTRIBUTE_CODES") or "sku,name,price,url_key,url_path").split(",")
            if x.strip()
        ],
    )


def load_magento_baseline_config() -> MagentoBaselineConfig:
    raw_codes = os.getenv("MAGENTO_CONFIGURABLE_ATTRIBUTE_CODES", "").strip()
    codes = [c.strip().lower() for c in raw_codes.split(",") if c.strip()] if raw_codes else []
    web_id = os.getenv("MAGENTO_DEFAULT_WEBSITE_ID", "").strip()
    all_cat = os.getenv("MAGENTO_ALL_PRODUCTS_CATEGORY_ID", "").strip()
    attr_set_name = os.getenv("MAGENTO_DEFAULT_ATTRIBUTE_SET_NAME", "").strip()
    source_code = os.getenv("MAGENTO_DEFAULT_SOURCE_CODE", "").strip() or None
    default_msi = os.getenv("MAGENTO_DEFAULT_MSI_QTY", "1").strip()
    default_msi_qty = float(default_msi) if default_msi and default_msi.replace(".", "").isdigit() else 1.0
    return MagentoBaselineConfig(
        ttl_hours=int(os.getenv("MAGENTO_BASELINE_TTL_HOURS", "24") or "24"),
        configurable_attribute_codes=codes,
        default_website_id=int(web_id) if web_id and web_id.isdigit() else None,
        all_products_category_id=int(all_cat) if all_cat and all_cat.isdigit() else None,
        default_attribute_set_name=attr_set_name or None,
        default_source_code=source_code,
        default_msi_qty=default_msi_qty,
    )


@dataclass(frozen=True)
class MagentoMsiConfig:
    """MSI source-item assignment policy. Environment-variable driven."""

    default_qty_if_missing: float
    force_zero_qty_to_default: bool
    assign_all_sources_if_none: bool
    assign_all_sources_if_partial: bool
    preserve_existing_positive_qty: bool


@dataclass(frozen=True)
class MagentoMediaConfig:
    """Media upload policy."""

    require_product_existence: bool  # If True, never attempt media for SKU absent in Magento


def load_magento_msi_config() -> MagentoMsiConfig:
    default_qty = os.getenv("MAGENTO_MSI_DEFAULT_QTY_IF_MISSING", "1").strip()
    default_qty_val = float(default_qty) if default_qty and default_qty.replace(".", "").replace("-", "").isdigit() else 1.0
    return MagentoMsiConfig(
        default_qty_if_missing=default_qty_val,
        force_zero_qty_to_default=_bool(os.getenv("MAGENTO_MSI_FORCE_ZERO_QTY_TO_DEFAULT"), True),
        assign_all_sources_if_none=_bool(os.getenv("MAGENTO_MSI_ASSIGN_ALL_SOURCES_IF_NONE"), True),
        assign_all_sources_if_partial=_bool(os.getenv("MAGENTO_MSI_ASSIGN_ALL_SOURCES_IF_PARTIAL"), True),
        preserve_existing_positive_qty=_bool(os.getenv("MAGENTO_MSI_PRESERVE_EXISTING_POSITIVE_QTY"), True),
    )


def load_magento_media_config() -> MagentoMediaConfig:
    return MagentoMediaConfig(
        require_product_existence=_bool(os.getenv("MAGENTO_MEDIA_REQUIRE_PRODUCT_EXISTENCE"), True),
    )


def magento_always_enabled_ignore_feed() -> bool:
    """
    When True: ignore product_online from feed for Magento status.
    Always set status=1 (enabled) for parent + child during sync.
    Only disable via explicit magento_product_override with status=0.
    """
    return _bool(os.getenv("MAGENTO_ALWAYS_ENABLED_IGNORE_FEED"), True)


def get_dimension_axes() -> frozenset[str]:
    """
    Axis codes that use {axis}_in as the primary value source (Plytix dimension columns).
    When axis is in this set, prefer {axis}_in over {axis} for child value lookup.
    Comma-separated env: MAGENTO_DIMENSION_AXES (default: width,height,depth,angle)
    """
    raw = os.getenv("MAGENTO_DIMENSION_AXES", "width,height,depth,angle").strip()
    if not raw:
        return frozenset()
    return frozenset(x.strip().lower() for x in raw.split(",") if x.strip())


def load_magento_config() -> MagentoNormalizeConfig:
    keep_cols = _dedupe_preserve_order(_csv_list(os.getenv("MAGENTO_KEEP_COLS")))
    if not keep_cols:
        raise RuntimeError("MAGENTO_KEEP_COLS cannot be empty")

    return MagentoNormalizeConfig(
        keep_cols=keep_cols,
        seed_children_col=os.getenv("MAGENTO_SEED_CHILDREN_COL", "variant_list").strip(),
        axis_col=os.getenv("MAGENTO_AXIS_COL", "configurable_variation_axis").strip(),
        labels_col=os.getenv("MAGENTO_LABELS_COL", "configurable_variation_labels").strip(),
        additional_col=os.getenv("MAGENTO_ADDITIONAL_COL", "additional_attributes").strip(),
        also_pack_cols=_dedupe_preserve_order(_csv_list(os.getenv("MAGENTO_ALSO_PACK_COLS"))),
        drop_before_pack=_csv_list(os.getenv("MAGENTO_DROP_BEFORE_PACK")),
        drop_after_compute=_csv_list(os.getenv("MAGENTO_DROP_AFTER_COMPUTE")),
        hardcoded_additional=_json_dict(os.getenv("MAGENTO_HARDCODED_ADDITIONAL")),
        default_attribute_set=os.getenv("MAGENTO_DEFAULT_ATTRIBUTE_SET", "Default").strip() or "Default",
        empty_to_default_attribute_set=_bool(os.getenv("MAGENTO_EMPTY_TO_DEFAULT_ATTRIBUTE_SET"), True),
    )
