#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
H&M Packing List Download Automation
------------------------------------
This script automates the retrieval of packing list PDFs from the H&M supplier portal.

Key capabilities:
 - Navigates through the Microsoft login flow (email + password) and pauses for OTP input
 - Opens the PLCM toolbox application inside the supplier portal
 - Searches for one or many order numbers (from CLI or CSV) and locates the desired store rows
 - Extracts the PDF export link (Metric), downloads the packing list file, and renames it using the required convention

Usage:
    python hm_packing_list_download.py <order_number> <store_code> <output_dir> \
        [--invoice-number <INVOICE>] [--email <EMAIL>] [--password <PASSWORD>] \
        [--headless] [--manual-login] [--session-dir <DIR>]

    python hm_packing_list_download.py --output-dir <path/to/output> \
        --input-csv path/to/orders.csv [--headless] [--manual-login] \
        [--email <EMAIL>] [--password <PASSWORD>] [--session-dir <DIR>]

Environment variables:
    HM_SUPPLIER_EMAIL
    HM_SUPPLIER_PASSWORD

These values are used when --email/--password are not provided via CLI arguments.
"""

import argparse
import csv
import json
import logging
import os
import sys
import time
import re
import subprocess
import shutil
from datetime import datetime
from typing import Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlencode, urljoin, urlparse

# Use Electron browser instead of Selenium
try:
    from shared.electron_browser import ElectronBrowser, By, ElectronWait, EC
except ImportError:
    # Fallback for direct execution
    import sys
    import os
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    from automation_scripts.shared.electron_browser import ElectronBrowser, By, ElectronWait, EC

# Compatibility exceptions
class NoSuchElementException(Exception):
    pass

class TimeoutException(Exception):
    pass

# Compatibility - Keys is not yet implemented
class Keys:
    RETURN = '\n'
    ENTER = '\n'

# Windows registry access for Chrome version detection
try:
    import winreg
    HAS_WINREG = True
except ImportError:
    HAS_WINREG = False

SUPPLIER_HOME = "https://supplierportal.hm.com/home"
PACKING_LIST_EXPORT_URL = "https://spc.az.hmgroup.com/spc/portal/packingListExportPackerViewPdf.view"
PAGELOAD_TIMEOUT = 120
DEFAULT_WAIT = 45
DOWNLOAD_TIMEOUT = 180

CSV_FIELD_ALIASES = {
    "order_number": ["order_number"],
    "store_code": ["store_code"],
    "invoice_number": ["invoice_number"],
}
INVALID_FILENAME_CHARS = set('<>:"/\\\\|?*')


def _detect_chrome_binary_paths():
    candidates = []
    for path in [
        os.environ.get('GOOGLE_CHROME_BIN'),
        '/usr/bin/google-chrome',
        '/usr/bin/google-chrome-stable',
        '/usr/bin/chromium',
        '/usr/bin/chromium-browser',
        '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
        'C:/Program Files/Google/Chrome/Application/chrome.exe',
        'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe'
    ]:
        if path and os.path.exists(path):
            candidates.append(path)
    return candidates


def get_installed_chrome_major_version():
    """Detect the major version of installed Chrome/Chromium browser.
    
    Returns:
        int: Major version number (e.g., 141) or None if not detected
    """
    # Try detected binary paths first
    for binary in _detect_chrome_binary_paths():
        try:
            out = subprocess.check_output([binary, '--version'], stderr=subprocess.STDOUT, timeout=5)
            text = out.decode('utf-8', errors='ignore')
            # Match patterns like "Chrome 141.0.7390.123" or "Chromium 141.0.7390.123"
            m = re.search(r'(?:Chrome|Chromium)\s+([0-9]+)\.', text)
            if m:
                version = int(m.group(1))
                return version
        except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError, ValueError):
            continue
        except Exception:
            continue
    
    # Fallback: try plain `google-chrome --version` commands
    for cmd in ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']:
        try:
            out = subprocess.check_output([cmd, '--version'], stderr=subprocess.STDOUT, timeout=5)
            text = out.decode('utf-8', errors='ignore')
            m = re.search(r'([0-9]+)\.', text)
            if m:
                version = int(m.group(1))
                return version
        except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError, ValueError):
            pass
        except Exception:
            pass
    
    # Windows-specific: Try reading from registry
    if HAS_WINREG and os.name == 'nt':
        try:
            # Try multiple registry paths where Chrome version might be stored
            registry_paths = [
                (winreg.HKEY_CURRENT_USER, r'Software\Google\Chrome\BLBeacon'),
                (winreg.HKEY_LOCAL_MACHINE, r'Software\Google\Chrome\BLBeacon'),
                (winreg.HKEY_CURRENT_USER, r'Software\Google\Update\ClientState\{8A69D345-D564-463C-AFF1-A69D9E530F96}'),
                (winreg.HKEY_LOCAL_MACHINE, r'Software\Google\Update\ClientState\{8A69D345-D564-463C-AFF1-A69D9E530F96}'),
            ]
            
            for hkey, path in registry_paths:
                try:
                    key = winreg.OpenKey(hkey, path)
                    try:
                        # Try to get version from 'version' value
                        version_str, _ = winreg.QueryValueEx(key, 'version')
                        m = re.search(r'([0-9]+)\.', str(version_str))
                        if m:
                            version = int(m.group(1))
                            winreg.CloseKey(key)
                            return version
                    except FileNotFoundError:
                        pass
                    finally:
                        winreg.CloseKey(key)
                except (FileNotFoundError, OSError):
                    continue
        except Exception:
            pass
    
    # Windows-specific: Try reading from Chrome's version file
    if os.name == 'nt':
        version_file_paths = [
            os.path.expanduser(r'~\AppData\Local\Google\Chrome\Application\version'),
            r'C:\Program Files\Google\Chrome\Application\version',
            r'C:\Program Files (x86)\Google\Chrome\Application\version',
        ]
        for version_file in version_file_paths:
            try:
                if os.path.exists(version_file):
                    with open(version_file, 'r', encoding='utf-8', errors='ignore') as f:
                        content = f.read()
                        m = re.search(r'([0-9]+)\.', content)
                        if m:
                            version = int(m.group(1))
                            return version
            except Exception:
                continue
    
    return None


def ensure_uc_cache_directory():
    """Ensure undetected_chromedriver cache directory exists with proper permissions.
    
    Returns:
        str: Path to the cache directory that was ensured, or None if failed
    """
    cache_dirs = [
        os.path.expanduser('~/.local/share/undetected_chromedriver'),  # Linux
        os.path.expanduser('~/AppData/Roaming/undetected_chromedriver'),  # Windows
        os.path.expanduser('~/Library/Application Support/undetected_chromedriver'),  # macOS
    ]
    
    for cache_dir in cache_dirs:
        try:
            # Create directory if it doesn't exist
            os.makedirs(cache_dir, mode=0o755, exist_ok=True)
            # Verify it's actually a directory and we can write to it
            if os.path.isdir(cache_dir) and os.access(cache_dir, os.W_OK):
                return cache_dir
        except Exception:
            continue
    
    return None


def clear_uc_cache():
    """Clear undetected_chromedriver cache from common locations"""
    cache_dirs = [
        os.path.expanduser('~/.local/share/undetected_chromedriver'),  # Linux
        os.path.expanduser('~/AppData/Roaming/undetected_chromedriver'),  # Windows
        os.path.expanduser('~/Library/Application Support/undetected_chromedriver'),  # macOS
    ]
    for cache_dir in cache_dirs:
        try:
            if os.path.isdir(cache_dir):
                shutil.rmtree(cache_dir, ignore_errors=True)
        except Exception:
            pass


def validate_driver_file(cache_dir=None, logger=None):
    """Validate that ChromeDriver executable exists and is accessible.
    
    Args:
        cache_dir: Optional cache directory path to check
        logger: Optional logger instance for logging
        
    Returns:
        bool: True if driver file appears to exist, False otherwise
    """
    if not cache_dir:
        cache_dir = ensure_uc_cache_directory()
    
    if not cache_dir:
        return False
    
    # Check common driver file locations
    driver_paths = [
        os.path.join(cache_dir, 'undetected_chromedriver'),
        os.path.join(cache_dir, 'undetected', 'chromedriver-linux64', 'chromedriver'),
        os.path.join(cache_dir, 'undetected', 'chromedriver-win64', 'chromedriver.exe'),
        os.path.join(cache_dir, 'undetected', 'chromedriver-mac-x64', 'chromedriver'),
    ]
    
    for driver_path in driver_paths:
        if os.path.exists(driver_path):
            # Check if it's executable (on Unix-like systems)
            if os.name != 'nt' and not os.access(driver_path, os.X_OK):
                try:
                    os.chmod(driver_path, 0o755)
                except Exception:
                    pass
            if logger:
                logger.debug(f"✅ Found driver file: {driver_path}")
            return True
    
    return False


def validate_driver_session(driver, logger=None):
    """Validate that the ChromeDriver session is actually working.
    
    Args:
        driver: ChromeDriver instance to validate
        logger: Optional logger instance for logging
        
    Returns:
        bool: True if session is working, False otherwise
    """
    try:
        # Try a simple command to verify the session is alive
        _ = driver.current_url
        return True
    except Exception as e:
        error_msg = str(e)
        if logger:
            # Check for connection errors
            if "Connection refused" in error_msg or "Failed to establish" in error_msg:
                logger.warning(f"⚠️ Driver session connection error: {error_msg}")
            else:
                logger.warning(f"⚠️ Driver session validation failed: {error_msg}")
        return False


def build_default_options(headless=True, user_data_dir=None, window_size='1366,768', incognito=True):
    """Build ChromeOptions with proper headless configuration.
    
    Args:
        headless: If True, run Chrome in headless mode (no visible window)
        user_data_dir: Optional user data directory path (if provided, cache will be preserved for session persistence)
        window_size: Window size string like '1920,1080'
        incognito: If True, run Chrome in incognito/private mode (default True for backward compatibility)
                   Note: When user_data_dir is provided, incognito is automatically disabled for session persistence
    
    Returns:
        uc.ChromeOptions: Configured ChromeOptions object
    """
    opts = uc.ChromeOptions()
    
    # When user_data_dir is provided, we need to preserve cache and disable incognito for session persistence
    # Session persistence requires non-incognito mode and cache to be enabled
    if user_data_dir:
        incognito = False  # Override incognito when using user_data_dir for session persistence
    
    # Headless mode configuration - must be set first
    if headless:
        # Primary headless flag (--headless=new is the modern headless mode)
        opts.add_argument('--headless=new')
        opts.add_argument('--disable-gpu')  # Required for headless on Windows
        opts.add_argument('--remote-debugging-port=0')  # Disable remote debugging in headless
        opts.add_argument('--disable-software-rasterizer')
        # Window positioning to move off-screen if window appears
        opts.add_argument('--window-position=-2000,-2000')
        # Additional flags to prevent window visibility
        opts.add_argument('--hide-scrollbars')
        opts.add_argument('--mute-audio')
        opts.add_argument('--disable-background-timer-throttling')
        opts.add_argument('--disable-backgrounding-occluded-windows')
        opts.add_argument('--disable-renderer-backgrounding')
        opts.add_argument('--disable-features=TranslateUI')
        opts.add_argument('--disable-ipc-flooding-protection')
        # Additional aggressive flags to prevent any window visibility
        opts.add_argument('--disable-infobars')
        opts.add_argument('--disable-notifications')
        opts.add_argument('--disable-popup-blocking')
        opts.add_argument('--disable-default-apps')
        opts.add_argument('--disable-session-crashed-bubble')
        opts.add_argument('--disable-component-update')
        opts.add_argument('--disable-sync')
        opts.add_argument('--no-pings')
        opts.add_argument('--disable-hang-monitor')
        opts.add_argument('--disable-prompt-on-repost')
        opts.add_argument('--disable-domain-reliability')
        opts.add_argument('--disable-client-side-phishing-detection')
        opts.add_argument('--disable-component-extensions-with-background-pages')
        opts.add_argument('--force-color-profile=srgb')
        opts.add_argument('--metrics-recording-only')
        opts.add_argument('--disable-breakpad')
    else:
        # Even in non-headless mode, disable GPU can help with stability
        opts.add_argument('--disable-gpu')
    
    # Common options for both headless and non-headless
    opts.add_argument('--no-sandbox')
    opts.add_argument('--disable-dev-shm-usage')
    opts.add_argument('--disable-blink-features=AutomationControlled')
    # Only add --incognito flag when incognito=True and user_data_dir is not provided
    if incognito and not user_data_dir:
        opts.add_argument('--incognito')
    opts.add_argument(f'--window-size={window_size}')
    
    # Only disable cache when user_data_dir is NOT provided (to allow session persistence)
    # When user_data_dir is provided, cache must be enabled for cookies and session storage to persist
    if not user_data_dir:
        opts.add_argument('--disable-cache')
        opts.add_argument('--disable-application-cache')
        opts.add_argument('--disk-cache-size=0')
    
    opts.add_argument('--no-first-run')
    opts.add_argument('--no-default-browser-check')
    opts.add_argument('--disable-extensions')
    opts.add_argument('--disable-plugins')

    # Prefer an existing Chrome binary if present
    for path in _detect_chrome_binary_paths():
        opts.binary_location = path
        break

    if user_data_dir:
        opts.add_argument('--user-data-dir=' + user_data_dir)

    return opts


def get_uc_driver(headless=True, user_data_dir=None, prefs=None, window_size='1366,768', retries=2, logger=None, incognito=True):
    """Get undetected Chrome driver with automatic version matching and retry logic.
    
    Creates fresh ChromeOptions for each attempt to avoid reuse errors.
    Automatically matches ChromeDriver version to installed Chrome version.
    
    Args:
        headless: If True, run Chrome in headless mode (no visible browser window)
        user_data_dir: Optional user data directory path
        prefs: Dictionary of Chrome preferences (download settings, etc.)
        window_size: Window size string like '1920,1080'
        retries: Number of retry attempts (unused, kept for backward compatibility)
        logger: Logger instance for logging messages
        incognito: If True, run Chrome in incognito/private mode (default True for backward compatibility)
    """
    # Set environment variable to force headless mode
    if headless:
        os.environ['CHROME_HEADLESS'] = '1'
        os.environ['DISPLAY'] = ':99'  # For Linux systems
    
    major = get_installed_chrome_major_version()
    attempt = 0
    last_err = None
    connection_error_detected = False
    
    if logger:
        if major:
            logger.info(f"🔍 Detected Chrome major version: {major}")
        logger.info(f"🔧 Headless mode: {'ENABLED' if headless else 'DISABLED'}")
    
    if logger and headless:
        logger.info("📌 Chrome will run in headless mode (no visible browser window)")

    # Ensure cache directory exists before attempting driver creation
    cache_dir = ensure_uc_cache_directory()
    if logger and cache_dir:
        logger.debug(f"✅ Cache directory ensured: {cache_dir}")
    elif logger:
        logger.warning("⚠️ Could not ensure cache directory exists, continuing anyway")

    # Build version ring: prefer exact match, then try -1, never try +1
    ring = []
    if major:
        # Try exact version first (most reliable)
        ring.append(major)
        # Try -1 as fallback (older compatible version)
        if major > 1:
            ring.append(major - 1)
    else:
        # If version detection fails, let uc auto-manage
        ring.append(None)

    # Track if we've seen version mismatch errors (driver too new for Chrome)
    version_mismatch_detected = False
    detected_chrome_version = None
    
    for vm in ring:
        attempt += 1
        try:
            if logger:
                logger.info(f"🧩 Starting Chrome (attempt {attempt}) with version_main={vm}")
            
            # Create fresh ChromeOptions for each attempt to avoid reuse errors
            opts = build_default_options(headless=headless, user_data_dir=user_data_dir, window_size=window_size, incognito=incognito)
            if prefs:
                opts.add_experimental_option('prefs', prefs)
            
            # Launch with specified version
            if vm:
                driver = uc.Chrome(options=opts, use_subprocess=True, version_main=vm)
            else:
                driver = uc.Chrome(options=opts, use_subprocess=True)
            
            # Validate driver file exists (if we can check)
            if cache_dir and not validate_driver_file(cache_dir, logger):
                if logger:
                    logger.warning("⚠️ Driver file validation failed, but driver was created. Continuing...")
            
            # Validate session is working
            if not validate_driver_session(driver, logger):
                connection_error_detected = True
                try:
                    driver.quit()
                except Exception:
                    pass
                raise Exception("Driver session validation failed: connection refused or session lost")
            
            if logger:
                logger.info("✅ Chrome driver created and session validated successfully")
            return driver
            
        except Exception as e:
            last_err = e
            error_msg = str(e)
            
            # Check for connection errors
            if "Connection refused" in error_msg or "Failed to establish" in error_msg or "Connection refused" in str(type(e).__name__):
                connection_error_detected = True
                if logger:
                    logger.warning(f"⚠️ Connection error detected (attempt {attempt}): {error_msg}")
            
            # Check if error indicates version mismatch (driver too new for Chrome)
            if "only supports Chrome version" in error_msg and "Current browser version" in error_msg:
                version_mismatch_detected = True
                # Extract the actual Chrome version from error message
                # Error format: "This version of ChromeDriver only supports Chrome version 142\nCurrent browser version is 141.0.7390.123"
                browser_version_match = re.search(r'Current browser version is (\d+)\.', error_msg)
                if browser_version_match:
                    detected_chrome_version = int(browser_version_match.group(1))
                    if logger:
                        logger.warning(f"⚠️ Version mismatch detected: Driver too new. Chrome is version {detected_chrome_version}")
                
                if logger:
                    logger.warning(f"⚠️ Version mismatch error: {error_msg}")
                
                # If we detected the Chrome version from error, use it immediately
                if detected_chrome_version and detected_chrome_version != vm:
                    if logger:
                        logger.info(f"🔁 Breaking out of version ring to try detected Chrome version: {detected_chrome_version}")
                    break  # Exit the loop to try the detected version
            
            # Check for driver download/file errors
            if "No such file or directory" in error_msg or "Unable to obtain driver" in error_msg:
                if logger:
                    logger.warning(f"⚠️ Driver download/file error (attempt {attempt}): {error_msg}")
            
            if logger:
                logger.warning(f"Chrome launch failed (version_main={vm}): {e}")
            
            # Only clear cache if we detected a version mismatch or connection error
            # Don't clear cache on every retry as it may interrupt downloads
            if version_mismatch_detected or connection_error_detected:
                if logger:
                    logger.info("🧹 Clearing cache due to version mismatch or connection error...")
                clear_uc_cache()
                time.sleep(5)  # Increased wait time to allow cache clearing and downloads to complete
            else:
                # Small delay before retry without clearing cache
                time.sleep(2)
    
    # If we detected version mismatch, try the detected Chrome version immediately
    if version_mismatch_detected and detected_chrome_version:
        try:
            if logger:
                logger.info(f"🔁 Trying detected Chrome version (version_main={detected_chrome_version}) due to version mismatch")
            
            # Create fresh ChromeOptions
            opts = build_default_options(headless=headless, user_data_dir=user_data_dir, window_size=window_size, incognito=incognito)
            if prefs:
                opts.add_experimental_option('prefs', prefs)
            
            # Clear cache before trying detected version
            clear_uc_cache()
            time.sleep(5)  # Increased wait time
            
            driver = uc.Chrome(options=opts, use_subprocess=True, version_main=detected_chrome_version)
            
            # Validate session
            if not validate_driver_session(driver, logger):
                connection_error_detected = True
                try:
                    driver.quit()
                except Exception:
                    pass
                raise Exception("Driver session validation failed after version match")
            
            if logger:
                logger.info("✅ Chrome driver created with detected version and session validated")
            return driver
            
        except Exception as e:
            if logger:
                logger.warning(f"Chrome launch failed with version_main={detected_chrome_version}: {e}")
            last_err = e
            error_msg = str(e)
            if "Connection refused" in error_msg or "Failed to establish" in error_msg:
                connection_error_detected = True
    
    # If we detected version mismatch but couldn't extract version, try major-1 and major-2
    if version_mismatch_detected and major and major > 2:
        for fallback_version in [major - 1, major - 2]:
            if fallback_version <= 0:
                continue
            try:
                if logger:
                    logger.info(f"🔁 Trying older ChromeDriver version (version_main={fallback_version}) due to version mismatch")
                
                # Create fresh ChromeOptions
                opts = build_default_options(headless=headless, user_data_dir=user_data_dir, window_size=window_size, incognito=incognito)
                if prefs:
                    opts.add_experimental_option('prefs', prefs)
                
                # Clear cache before fallback attempt
                clear_uc_cache()
                time.sleep(5)  # Increased wait time
                
                driver = uc.Chrome(options=opts, use_subprocess=True, version_main=fallback_version)
                
                # Validate session
                if not validate_driver_session(driver, logger):
                    connection_error_detected = True
                    try:
                        driver.quit()
                    except Exception:
                        pass
                    raise Exception("Driver session validation failed with fallback version")
                
                if logger:
                    logger.info("✅ Chrome driver created with fallback version and session validated")
                return driver
                
            except Exception as e:
                if logger:
                    logger.warning(f"Chrome launch failed with version_main={fallback_version}: {e}")
                last_err = e
                error_msg = str(e)
                if "Connection refused" in error_msg or "Failed to establish" in error_msg:
                    connection_error_detected = True

    # Final fallback: only use auto-detection if we haven't detected a version mismatch
    # If version mismatch was detected, we should have tried the correct version already
    if not version_mismatch_detected:
        try:
            if logger:
                logger.info('🔁 Final fallback: launching uc.Chrome() with auto-version detection')
            
            # Create fresh ChromeOptions for final fallback
            opts = build_default_options(headless=headless, user_data_dir=user_data_dir, window_size=window_size, incognito=incognito)
            if prefs:
                opts.add_experimental_option('prefs', prefs)
            
            # Clear cache before final attempt if we had connection errors
            if connection_error_detected:
                if logger:
                    logger.info("🧹 Clearing cache before final fallback attempt...")
                clear_uc_cache()
                time.sleep(5)  # Increased wait time
            
            # Let undetected_chromedriver auto-detect and download the correct version
            driver = uc.Chrome(options=opts, use_subprocess=True)
            
            # Validate session
            if not validate_driver_session(driver, logger):
                connection_error_detected = True
                try:
                    driver.quit()
                except Exception:
                    pass
                raise Exception("Driver session validation failed in final fallback")
            
            if logger:
                logger.info("✅ Chrome driver created with auto-detection and session validated")
            return driver
            
        except Exception as e:
            if logger:
                logger.error(f"❌ Final Chrome launch failed: {e}")
            raise last_err or e
    else:
        # If we detected version mismatch but all attempts failed, raise the last error
        if logger:
            logger.error(f"❌ All Chrome launch attempts failed after version mismatch detection")
        raise last_err or Exception("Failed to launch Chrome after version mismatch detection")


def setup_logging(output_dir: str) -> logging.Logger:
    """Configure structured logging to console only."""
    os.makedirs(output_dir, exist_ok=True)

    logger = logging.getLogger("hm_packing_list")
    logger.setLevel(logging.INFO)
    logger.propagate = False

    if not logger.handlers:
        formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")

        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setFormatter(formatter)
        console_handler.setLevel(logging.INFO)
        logger.addHandler(console_handler)

    logger.info("=" * 100)
    logger.info("🚀 Starting H&M Packing List download automation")
    logger.info("=" * 100)

    return logger


def create_driver(download_dir: str, headless: bool, session_dir: Optional[str], logger: logging.Logger, incognito: Optional[bool] = None):
    """Instantiate undetected Chrome with download preferences."""
    os.makedirs(download_dir, exist_ok=True)

    prefs = {
        "download.default_directory": os.path.abspath(download_dir),
        "download.prompt_for_download": False,
        "download.directory_upgrade": True,
        "safebrowsing.enabled": True,
        "plugins.always_open_pdf_externally": True,
    }

    if incognito is None:
        incognito = False if session_dir else True

    # Get job_id from sys.argv if available (typically 3rd argument), otherwise use default
    job_id = sys.argv[3] if len(sys.argv) > 3 else os.environ.get('JOB_ID', 'hm_packing_list_download')
    driver = ElectronBrowser.create(
        job_id=job_id,
        headless=headless,
        logger=logger
    )
    driver.set_page_load_timeout(PAGELOAD_TIMEOUT)
    driver.implicitly_wait(3)
    logger.info("✅ Chrome driver initialized")
    return driver


def _normalize_key(key: Optional[str]) -> str:
    if not key:
        return ""
    return key.strip().lower().replace(" ", "_")


def _extract_value(row: Dict[str, str], field_name: str) -> str:
    aliases = CSV_FIELD_ALIASES.get(field_name, [])
    for alias in aliases:
        value = row.get(alias)
        if value:
            return value.strip()
    return ""


def load_jobs_from_csv(csv_path: str, logger: logging.Logger) -> List[Dict[str, str]]:
    """Load order/store/invoice combinations from a CSV file."""
    if not os.path.isfile(csv_path):
        raise FileNotFoundError(f"Input CSV not found: {csv_path}")

    jobs: List[Dict[str, str]] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        if not reader.fieldnames:
            raise ValueError("Input CSV is missing a header row.")

        for row_index, row in enumerate(reader, start=1):
            normalized = {_normalize_key(k): (v or "").strip() for k, v in row.items() if k}
            order_number = _extract_value(normalized, "order_number")
            store_code = _extract_value(normalized, "store_code")
            invoice_number = _extract_value(normalized, "invoice_number")

            if not (order_number and store_code and invoice_number):
                logger.warning(
                    "⚠️ Skipping CSV row %s due to missing data (order/store/invoice required).",
                    row_index,
                )
                continue

            jobs.append(
                {
                    "order_number": order_number,
                    "store_code": store_code,
                    "invoice_number": invoice_number,
                    "row": row_index,
                }
            )

    if not jobs:
        raise ValueError("No valid rows found in the input CSV.")

    logger.info("📄 Loaded %d job(s) from CSV: %s", len(jobs), csv_path)
    return jobs


def sanitize_filename_component(value: str) -> str:
    """Remove characters that are invalid in filenames while preserving spaces."""
    value = (value or "").strip()
    if not value:
        return "UNKNOWN"
    sanitized_chars = []
    for ch in value:
        if ch in INVALID_FILENAME_CHARS or ord(ch) < 32:
            sanitized_chars.append("_")
        else:
            sanitized_chars.append(ch)
    sanitized = "".join(sanitized_chars)
    sanitized = " ".join(sanitized.split())
    return sanitized or "UNKNOWN"


def build_pdf_filename(sequence: int, invoice_number: str, order_number: str, store_code: str) -> str:
    """Construct the target PDF filename using the required naming convention."""
    components = [
        f"{sequence}".strip(),
        sanitize_filename_component(invoice_number),
        sanitize_filename_component(order_number),
        sanitize_filename_component(store_code),
    ]
    base_name = " ".join(comp for comp in components if comp)
    return f"{base_name}.pdf"


def rename_downloaded_file(downloaded_file: str, target_filename: str, logger: logging.Logger) -> str:
    """Rename the downloaded PDF to match the naming convention, avoiding collisions."""
    directory = os.path.dirname(downloaded_file)
    base, ext = os.path.splitext(target_filename)
    if not ext:
        ext = ".pdf"
    target_path = os.path.join(directory, f"{base}{ext}")

    if os.path.abspath(downloaded_file) == os.path.abspath(target_path):
        return downloaded_file

    counter = 1
    candidate_path = target_path
    while os.path.exists(candidate_path) and os.path.abspath(candidate_path) != os.path.abspath(downloaded_file):
        candidate_path = os.path.join(directory, f"{base} ({counter}){ext}")
        counter += 1

    os.replace(downloaded_file, candidate_path)
    logger.info("📝 Renamed PDF to %s", os.path.basename(candidate_path))
    return candidate_path


def wait_and_fill(wait: WebDriverWait, locator, value: str, clear: bool = True):
    """Wait for an input element and populate it."""
    elem = wait.until(EC.element_to_be_clickable(locator))
    if clear:
        elem.clear()
    elem.send_keys(value)
    return elem


def microsoft_login(driver, wait, email: str, password: str, logger: logging.Logger):
    """Complete Microsoft login sequence up to the OTP prompt."""
    logger.info("🧭 Navigating to supplier home for login")
    driver.get(SUPPLIER_HOME)

    try:
        wait_and_fill(wait, (By.ID, "i0116"), email)
        wait.until(EC.element_to_be_clickable((By.ID, "idSIButton9"))).click()
        logger.info("📨 Submitted email on Microsoft login page")
    except TimeoutException:
        logger.error("❌ Email input not found on login page")
        raise

    try:
        wait_and_fill(wait, (By.ID, "i0118"), password)
        wait.until(EC.element_to_be_clickable((By.ID, "idSIButton9"))).click()
        logger.info("🔐 Submitted password")
    except TimeoutException:
        logger.error("❌ Password step not reachable")
        raise

    try:
        sms_locator = (By.CSS_SELECTOR, "div[data-value='OneWaySMS']")
        WebDriverWait(driver, 30).until(EC.element_to_be_clickable(sms_locator))
        driver.execute_script("arguments[0].click();", driver.find_element(*sms_locator))
        logger.info("📱 Requested SMS verification code")
    except TimeoutException:
        logger.warning("⚠️ SMS option not detected; assuming alternative MFA flow")


def handle_otp(driver, wait, logger: logging.Logger):
    """Prompt user for OTP and submit to Microsoft."""
    logger.info("⌛ Waiting for OTP input page")
    try:
        wait.until(EC.presence_of_element_located((By.ID, "idTxtBx_SAOTCC_OTC")))
    except TimeoutException:
        logger.warning("⚠️ OTP input not presented; continuing without manual entry")
        return

    otp_code = ""
    while not otp_code:
        otp_code = input("Enter the SMS verification code: ").strip()
        if not otp_code:
            print("OTP cannot be empty. Please try again.")

    otp_box = driver.find_element(By.ID, "idTxtBx_SAOTCC_OTC")
    otp_box.clear()
    otp_box.send_keys(otp_code)
    logger.info("🔢 OTP entered, submitting...")

    try:
        wait.until(EC.element_to_be_clickable((By.ID, "idSubmit_SAOTCC_Continue"))).click()
    except TimeoutException:
        logger.warning("⚠️ OTP submit button not found, attempting Enter key")
        otp_box.send_keys(Keys.ENTER)

    for attempt in range(2):
        try:
            stay_signed_in = WebDriverWait(driver, 15).until(
                EC.element_to_be_clickable((By.ID, "idSIButton9"))
            )
            value = stay_signed_in.get_attribute("value") or ""
            if value.lower() in {"yes", "ok", "sign in", "stay signed in"}:
                driver.execute_script("arguments[0].click();", stay_signed_in)
                logger.info(f"✅ Responded '{value}' to stay-signed-in prompt (attempt {attempt + 1})")
                time.sleep(2)
            else:
                break
        except TimeoutException:
            break


def ensure_portal_loaded(driver, wait, logger: logging.Logger):
    """Wait until supplier portal home is ready."""
    logger.info("🧭 Waiting for supplier portal dashboard to load...")
    WebDriverWait(driver, 90).until(lambda d: "supplierportal.hm.com" in d.current_url.lower())
    try:
        wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "div.sp-toolbox-item")))
    except TimeoutException:
        logger.warning("⚠️ Toolbox elements not detected, continuing cautiously")


def open_plcm_app(driver, wait, logger: logging.Logger):
    """Open the PLCM application (Packing Lists & Carton Markings)."""
    logger.info("📦 Opening PLCM toolbox app")
    initial_handles = driver.window_handles[:]

    plcm_selectors = [
        (By.CSS_SELECTOR, "a#plcm.sp-toolbox-item"),
        (By.CSS_SELECTOR, "a[href='/Toolbox/PLCM']"),
        (By.XPATH, "//a[contains(@href,'/Toolbox/PLCM')]"),
    ]

    clicked = False
    for locator in plcm_selectors:
        try:
            link = wait.until(EC.element_to_be_clickable(locator))
            driver.execute_script("arguments[0].setAttribute('target','_self');", link)
            driver.execute_script("arguments[0].click();", link)
            clicked = True
            break
        except TimeoutException:
            continue

    if not clicked:
        raise TimeoutException("PLCM link not found on supplier portal home page.")

    time.sleep(3)
    if len(driver.window_handles) > len(initial_handles):
        new_window = list(set(driver.window_handles) - set(initial_handles))[0]
        driver.switch_to.window(new_window)

    WebDriverWait(driver, 60).until(lambda d: "spc.az.hmgroup.com" in d.current_url.lower())
    logger.info(f"✅ PLCM application loaded: {driver.current_url}")


def wait_for_table_ready(driver, logger: logging.Logger, timeout: int = 90) -> None:
    """Wait for PLCM results table to finish loading with hydrated rows."""
    logger.info("⌛ Waiting for PLCM results table to populate...")

    def table_hydrated(_driver):
        rows = _driver.find_elements(By.CSS_SELECTOR, "table tbody tr")
        if not rows:
            return False
        for row in rows:
            cells = row.find_elements(By.TAG_NAME, "td")
            if len(cells) < 5:
                continue
            has_flag = bool(row.find_elements(By.CSS_SELECTOR, "span[class*='flag']"))
            has_actions = bool(row.find_elements(By.CSS_SELECTOR, "i.mdi-barcode")) and bool(
                row.find_elements(By.CSS_SELECTOR, "i.mdi-dots-vertical")
            )
            if has_flag and has_actions:
                return True
        return False

    try:
        WebDriverWait(driver, timeout).until(table_hydrated)
        logger.info("✅ PLCM table fully loaded with actionable rows")
    except TimeoutException as exc:
        logger.error("⚠️ PLCM table did not fully load within expected time")
        raise exc


def search_order(driver, wait, order_number: str, logger: logging.Logger):
    """Populate the order number field and trigger search."""
    logger.info(f"🔍 Searching for order number: {order_number}")

    try:
        input_element = wait.until(
            EC.element_to_be_clickable(
                (
                    By.XPATH,
                    "//label[contains(normalize-space(.), 'Order number')]/following::input[1]",
                )
            )
        )
    except TimeoutException:
        raise TimeoutException("Order number input field not found on PLCM page.")

    input_element.clear()
    input_element.send_keys(order_number)

    try:
        search_button = wait.until(
            EC.element_to_be_clickable(
                (
                    By.XPATH,
                    "//button[contains(@class,'SearchButton') and contains(., 'Search')]",
                )
            )
        )
        driver.execute_script("arguments[0].click();", search_button)
    except TimeoutException:
        raise TimeoutException("Search button not found on PLCM page.")

    wait_for_table_ready(driver, logger)


def wait_for_store_row(driver, store_code: str, logger: logging.Logger, timeout: int = 90):
    """Wait for the specific store row to appear fully hydrated."""
    logger.info(f"🔍 Waiting for store row: {store_code}")
    store_code_norm = store_code.strip().lower()

    def locate_row(_driver):
        rows = _driver.find_elements(By.CSS_SELECTOR, "table tbody tr")
        for row in rows:
            try:
                store_cell = row.find_element(By.XPATH, ".//td[3]//*[normalize-space(text())!='']")
            except NoSuchElementException:
                continue
            code_text = store_cell.text.strip().lower()
            if code_text != store_code_norm:
                continue
            has_barcode = bool(row.find_elements(By.CSS_SELECTOR, "i.mdi-barcode"))
            has_actions = bool(row.find_elements(By.CSS_SELECTOR, "i.mdi-dots-vertical"))
            if has_barcode and has_actions:
                return row
        return None

    try:
        row = WebDriverWait(driver, timeout).until(locate_row)
        logger.info(f"✅ Store row located for code {store_code}")
        return row
    except TimeoutException:
        raise NoSuchElementException(f"Store code {store_code} not found or row not fully loaded.")


def find_store_row(driver, store_code: str, logger: logging.Logger, retries: int = 3, delay: int = 2):
    """Locate the table row matching the provided store code."""
    last_exception = None
    for attempt in range(1, retries + 1):
        try:
            return wait_for_store_row(driver, store_code, logger)
        except NoSuchElementException as exc:
            last_exception = exc
            logger.warning(f"⚠️ Store code {store_code} not ready (attempt {attempt}/{retries}); retrying...")
            time.sleep(delay)
    raise last_exception or NoSuchElementException(f"Store code {store_code} not found in results table after retries.")


def extract_export_id(driver, row, wait, logger: logging.Logger) -> str:
    """Click the barcode icon to reveal the Metric download link and return its export id."""
    logger.info("🔗 Opening export menu for selected row")

    try:
        barcode_icon = row.find_element(By.CSS_SELECTOR, "i.mdi-barcode")
        driver.execute_script("arguments[0].click();", barcode_icon)
    except NoSuchElementException as exc:
        raise NoSuchElementException("Barcode icon not found in the row.") from exc

    try:
        metric_link = WebDriverWait(driver, 30).until(
            EC.presence_of_element_located(
                (By.CSS_SELECTOR, "a[target='_blank'][href*='packingListExportPackerViewPdf']")
            )
        )
    except TimeoutException:
        raise TimeoutException("Metric download link did not appear after clicking barcode icon.")

    href = metric_link.get_attribute("href")
    if not href:
        raise ValueError("Metric link href is empty.")

    full_url = urljoin(driver.current_url, href)
    parsed = urlparse(full_url)
    export_id = parse_qs(parsed.query).get("id", [None])[0]
    if not export_id:
        raise ValueError("Unable to extract export id from Metric link.")

    logger.info(f"🔐 Metric export id captured: {export_id}")
    driver.execute_script("document.body.click();")
    return export_id


def download_packing_list(driver, export_id: str, download_dir: str, logger: logging.Logger, unit_type: str = "metric") -> Tuple[str, str]:
    """Download the packing list PDF using the export id and wait for completion."""
    params = {"id": export_id, "unitType": unit_type}
    export_url = f"{PACKING_LIST_EXPORT_URL}?{urlencode(params)}"
    logger.info(f"📥 Initiating PDF download via direct URL: {export_url}")

    original_window = driver.current_window_handle
    try:
        driver.switch_to.new_window("tab")
    except Exception:
        driver.execute_script("window.open('about:blank','_blank');")
        driver.switch_to.window(driver.window_handles[-1])

    driver.get(export_url)
    time.sleep(5)

    logger.info("⏳ Waiting for PDF download to complete...")
    deadline = time.time() + DOWNLOAD_TIMEOUT
    downloaded_file = None
    while time.time() < deadline:
        pdf_candidates = [
            os.path.join(download_dir, fn)
            for fn in os.listdir(download_dir)
            if fn.lower().endswith(".pdf")
        ]
        crdownload_exists = any(
            fn.lower().endswith(".crdownload") for fn in os.listdir(download_dir)
        )
        if pdf_candidates and not crdownload_exists:
            downloaded_file = max(pdf_candidates, key=os.path.getmtime)
            break
        time.sleep(1)

    if not downloaded_file:
        raise TimeoutException("PDF download did not complete within the expected timeframe.")

    logger.info(f"✅ PDF downloaded: {downloaded_file}")

    try:
        driver.close()
        driver.switch_to.window(original_window)
    except Exception:
        pass

    return downloaded_file, export_url


def parse_args():
    parser = argparse.ArgumentParser(description="Download H&M packing list PDF(s) for given order/store entries.")
    parser.add_argument("order_number", nargs="?", help="Order number to search for (e.g., PO number).")
    parser.add_argument("store_code", nargs="?", help="Store code shown in the PLCM results table (e.g., CAW387).")
    parser.add_argument("output_dir", nargs="?", help="Directory where logs and downloads should be stored.")
    parser.add_argument(
        "--email",
        help="Supplier portal email (required unless --manual-login is supplied; defaults to HM_SUPPLIER_EMAIL env).",
    )
    parser.add_argument(
        "--password",
        help="Supplier portal password (required unless --manual-login is supplied; defaults to HM_SUPPLIER_PASSWORD env).",
    )
    parser.add_argument("--session-dir", help="Optional Chrome user data dir for session reuse.")
    parser.add_argument("--headless", action="store_true", help="Run Chrome in headless mode (default: visible).")
    parser.add_argument("--manual-login", action="store_true", help="Launch a visible browser for manual login before headless automation.")
    parser.add_argument(
        "--invoice-number",
        dest="invoice_number",
        help="Invoice number used when processing a single order/store via CLI arguments.",
    )
    parser.add_argument(
        "--input-csv",
        dest="input_csv",
        help="Path to an input CSV containing order_number, store_code, and invoice_number columns.",
    )
    parser.add_argument(
        "--output-dir",
        dest="output_dir_opt",
        help="Directory where logs and downloads should be stored (alternative to positional argument).",
    )
    return parser.parse_args()


def main():
    args = parse_args()

    output_dir = args.output_dir or args.output_dir_opt
    if not output_dir:
        print(
            "❌ Output directory must be provided either as a positional argument or via --output-dir.",
            file=sys.stderr,
        )
        return 1
    output_dir = os.path.abspath(output_dir)

    email = args.email or os.getenv("HM_SUPPLIER_EMAIL")
    password = args.password or os.getenv("HM_SUPPLIER_PASSWORD")

    if not args.manual_login and (not email or not password):
        print(
            "❌ Email and password must be provided via CLI arguments or environment variables. "
            "Use --manual-login to sign in manually if credentials are unavailable.",
            file=sys.stderr,
        )
        return 1

    logger = setup_logging(output_dir)
    if args.manual_login and (not email or not password):
        logger.info("🔐 Manual login mode enabled without stored credentials; reuse of session will rely on user sign-in.")
    download_dir = os.path.join(output_dir, "downloads")
    os.makedirs(download_dir, exist_ok=True)

    if args.input_csv:
        try:
            jobs = load_jobs_from_csv(args.input_csv, logger)
        except Exception as exc:
            logger.error("❌ Failed to load input CSV '%s': %s", args.input_csv, exc)
            return 1
    else:
        if not args.order_number or not args.store_code:
            logger.error("❌ Order number and store code are required when --input-csv is not provided.")
            return 1
        invoice_number = (args.invoice_number or "").strip()
        if not invoice_number:
            invoice_number = "UNKNOWN"
            logger.warning("⚠️ Invoice number not provided; using placeholder 'UNKNOWN' for naming.")
        jobs = [
            {
                "order_number": args.order_number.strip(),
                "store_code": args.store_code.strip(),
                "invoice_number": invoice_number,
                "row": None,
            }
        ]

    total_jobs = len(jobs)
    results: List[Dict[str, str]] = []

    driver = None
    session_dir = args.session_dir or os.path.join(output_dir, "sessions", "default")
    os.makedirs(session_dir, exist_ok=True)
    try:
        if args.manual_login:
            logger.info("🧑‍💻 Starting manual login handshake (visible browser)...")
            driver = create_driver(download_dir, headless=False, session_dir=session_dir, logger=logger, incognito=False)
            wait = WebDriverWait(driver, DEFAULT_WAIT)
            driver.get(SUPPLIER_HOME)
            logger.info("👉 Please complete the login (including MFA) in the browser window, then return here.")
            input("Press Enter once the supplier portal dashboard is fully visible...")
            ensure_portal_loaded(driver, wait, logger)
            logger.info("✅ Manual login confirmed, switching to headless mode for automation...")
            try:
                driver.quit()
            except Exception:
                pass
            driver = create_driver(download_dir, headless=True, session_dir=session_dir, logger=logger, incognito=False)
            wait = WebDriverWait(driver, DEFAULT_WAIT)
            driver.get(SUPPLIER_HOME)
            ensure_portal_loaded(driver, wait, logger)
        else:
            driver = create_driver(download_dir, headless=args.headless, session_dir=session_dir, logger=logger, incognito=False)
            wait = WebDriverWait(driver, DEFAULT_WAIT)
            driver.get(SUPPLIER_HOME)
            ensure_portal_loaded(driver, wait, logger)
            microsoft_login(driver, wait, email, password, logger)
            handle_otp(driver, wait, logger)
            ensure_portal_loaded(driver, wait, logger)
        open_plcm_app(driver, wait, logger)
        for index, job in enumerate(jobs, start=1):
            order_number = job["order_number"]
            store_code = job["store_code"]
            invoice_number = job["invoice_number"]
            logger.info(
                "🧾 Processing job %s/%s (Invoice: %s, Order: %s, Store: %s)",
                index,
                total_jobs,
                invoice_number,
                order_number,
                store_code,
            )
            try:
                search_order(driver, wait, order_number, logger)
                row = find_store_row(driver, store_code, logger)
                export_id = extract_export_id(driver, row, wait, logger)
                downloaded_file, export_url = download_packing_list(driver, export_id, download_dir, logger)
                target_filename = build_pdf_filename(index, invoice_number, order_number, store_code)
                final_path = rename_downloaded_file(downloaded_file, target_filename, logger)

                logger.info("✅ Completed job %s/%s → %s", index, total_jobs, os.path.basename(final_path))
                results.append(
                    {
                        "sequence": index,
                        "invoice_number": invoice_number,
                        "order_number": order_number,
                        "store_code": store_code,
                        "export_id": export_id,
                        "export_url": export_url,
                        "pdf_path": final_path,
                    }
                )
            except Exception as job_exc:
                logger.error(
                    "🚨 Job %s/%s failed for Order %s / Store %s (Invoice %s): %s",
                    index,
                    total_jobs,
                    order_number,
                    store_code,
                    invoice_number,
                    job_exc,
                )
                results.append(
                    {
                        "sequence": index,
                        "invoice_number": invoice_number,
                        "order_number": order_number,
                        "store_code": store_code,
                        "error": str(job_exc),
                    }
                )

        success_count = sum(1 for item in results if "pdf_path" in item)
        failure_count = total_jobs - success_count
        logger.info("🎯 Completed %s job(s): %s succeeded, %s failed.", total_jobs, success_count, failure_count)
        print(json.dumps({"results": results}))
        return 0 if failure_count == 0 else 1
    except Exception as exc:
        logger.error(f"🚨 Automation failed: {exc}", exc_info=True)
        if results:
            print(json.dumps({"results": results, "error": str(exc)}))
        else:
            print(json.dumps({"error": str(exc)}))
        return 1

    finally:
        if driver:
            try:
                driver.quit()
            except Exception:
                pass


if __name__ == "__main__":
    sys.exit(main())

