#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Utilities module - Contains logging, printing, and helper functions
"""

import os
import sys
import json
import subprocess
import shutil
import time
import threading
import platform
import zipfile
import re
from datetime import datetime

# Add parent to path for imports (check both root and code/ subfolder)
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, script_dir)

# Check if code folder exists (for reorganized structure)
code_dir = os.path.join(script_dir, "code")
if os.path.exists(code_dir):
    sys.path.insert(0, code_dir)

# Script Version - Defined here to avoid circular import issues
SCRIPT_VERSION = "2026.05.19.JH"
SATSUONI_TOOLS_VERSION = "v10.0.20"

# Unified Configuration File
CONFIG_FILE = "key_finder_config.json"

# Tool filename prefixes - used for case-insensitive, version-agnostic tool discovery.
# The script searches for any .exe whose lowercase name STARTS WITH this prefix.
# Drop a new version (e.g. KFXKeyExtractor30.exe) into code/tools/ and it will be
# found automatically on the next run - no code changes required.
KFXKEYEXTRACTOR_PREFIX = "kfxkeyextractor"
KFXARCHIVER_PREFIX = "kfxarchiver"

# Supported Kindle for PC versions for each tool.
# Both tools hard-code memory offsets for specific builds. Any version outside
# the supported list causes checkExecs() to dereference an invalid pointer and
# crash with STATUS_ACCESS_VIOLATION (0xC0000005 / exit code 3221225477).
#
# KFXKeyExtractor: 2.8.0 / 2.8.1 / 2.8.2  (3 versions)
# KFXArchiver:     2.8.0 / 2.8.1 / 2.8.2 / 2.8.3  (4 versions)
#
# Key implication: a user on Kindle 2.8.3 will see KFXKeyExtractor crash but
# KFXArchiver (alt retry / alt mode) will recover automatically.
# A user on 2.8.4+ has no supported tool at all.
SUPPORTED_KINDLE_VERSIONS = ['2.8.0', '2.8.1', '2.8.2']           # KFXKeyExtractor
SUPPORTED_KINDLE_VERSIONS_ALT = ['2.8.0', '2.8.1', '2.8.2', '2.8.3', '2.9.1']  # KFXArchiver (all versions combined)

# Maps K4PC version prefixes to the specific KFXArchiver tool (by prefix) that supports them.
# First matching entry wins.  Add a new entry here whenever a new KFXArchiver is released.
# The tool prefix must match the filename in code/tools/ (case-insensitive, without .exe).
#   Example: 'kfxarchiver291' matches 'KFXArchiver291.exe'
KFXARCHIVER_TOOL_MAP = [
    (['2.8.0', '2.8.1', '2.8.2', '2.8.3'], 'kfxarchiver283'),  # KFXArchiver283.exe
    (['2.9.1'],                              'kfxarchiver291'),  # KFXArchiver291.exe
]


def print_colored(message: str, color: str) -> None:
    """Print colored messages"""
    colors = {
        'cyan': '\033[96m',
        'green': '\033[92m',
        'yellow': '\033[93m',
        'red': '\033[91m',
        'magenta': '\033[95m',
        'end': '\033[0m'
    }
    print(f"{colors.get(color, '')}{message}{colors['end']}")


def print_step(message: str) -> None:
    print_colored(f"[*] {message}", 'cyan')


def print_ok(message: str) -> None:
    print_colored(f"[OK] {message}", 'green')


def print_warn(message: str) -> None:
    print_colored(f"[!] {message}", 'yellow')


def print_error(message: str) -> None:
    print_colored(f"[ERROR] {message}", 'red')


def print_done(message: str) -> None:
    print_colored(f"[DONE] {message}", 'magenta')


def parse_version(version_str):
    """
    Parse version string into comparable components.
    Format: YYYY.MM.DD[.build]JH
    
    Args:
        version_str: Version string (e.g., "2025.11.11.JH" or "2025.11.11.1.JH")
    
    Returns:
        tuple: (year, month, day, build) or None if parsing fails
        - build defaults to 0 if not present
    """
    try:
        version_clean = version_str.replace('.JH', '').replace('JH', '')
        parts = version_clean.split('.')
        
        if len(parts) < 3:
            return None
        
        year = int(parts[0])
        month = int(parts[1])
        day = int(parts[2])
        build = int(parts[3]) if len(parts) >= 4 else 0
        
        if not (2000 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31):
            return None
        
        return (year, month, day, build)
        
    except (ValueError, IndexError):
        return None


def compare_versions(version1, version2):
    """
    Compare two version strings chronologically.
    """
    v1 = parse_version(version1)
    v2 = parse_version(version2)
    
    if v1 is None or v2 is None:
        return None
    
    if v1 < v2:
        return -1
    elif v1 > v2:
        return 1
    else:
        return 0


def check_latest_version():
    """
    Check for latest version from GitHub repository releases
    Returns: (latest_version: str or None, status_text: str)
    """
    try:
        import urllib.request
        import json
        
        url = "https://api.github.com/repos/jadehawk/Kindle_Key_Finder/releases/latest"
        req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
        
        with urllib.request.urlopen(req, timeout=5) as response:
            data = json.loads(response.read().decode('utf-8'))
            tag_name = data.get('tag_name', '')
            
            if tag_name:
                latest_version = tag_name.lstrip('v')
                
                # Compare versions to determine status
                comparison = compare_versions(SCRIPT_VERSION, latest_version)
                
                if comparison is None:
                    status_text = ""
                elif comparison < 0:
                    status_text = "(Update Available)"
                elif comparison > 0:
                    status_text = "(Development Version)"
                else:
                    status_text = "(Latest)"
                
                return latest_version, status_text
            else:
                return None, ""
                
    except Exception as e:
        return None, ""


def print_banner_and_version():
    """Print ASCII banner and script version"""
    print_colored(r" _____         _                   _   _       _                  ____                ", 'green')
    print_colored(r"|_   _|__  ___| |__  _   _        | \ | | ___ | |_ ___  ___      / ___|___  _ __ ___  ", 'green')
    print_colored(r"  | |/ _ \/ __| '_ \| | | | _____ |  \| |/ _ \| __/ _ \/ __|    | |   / _ \| '_ ` _ \ ", 'green')
    print_colored(r"  | |  __/ (__| | | | |_| ||_____|| |\  | (_) | ||  __/\__ \  _ | |__| (_) | | | | | |", 'green')
    print_colored(r"  |_|\___|\___|_| |_|\__, |       |_| \_|\___/ \__\___||___/ (_) \____\___/|_| |_| |_|", 'green')
    print_colored(r"                     |___/                                                             ", 'green')
    print()
    print_step("Kindle Key Finder - Python Edition Wrapper")
    print(f"Script Version: {SCRIPT_VERSION}")
    print()
    
    print_step("Checking for updates...")
    latest_version, error = check_latest_version()
    
    if latest_version:
        comparison = compare_versions(SCRIPT_VERSION, latest_version)
        
        if comparison is None:
            print_colored("  (Unable to parse version - check manually)", 'cyan')
            print_colored("  Visit https://techy-notes.com for the latest version", 'cyan')
            print()
        elif comparison < 0:
            print()
            print_colored("=" * 70, 'yellow')
            print_colored(f"  ⚠   NEW VERSION AVAILABLE: {latest_version}", 'yellow')
            print_colored(f"  Current version: {SCRIPT_VERSION}", 'yellow')
            print_colored("  Visit https://techy-notes.com for the latest version", 'yellow')
            print_colored("=" * 70, 'yellow')
            print()
        elif comparison > 0:
            print_colored(f"  ℹ   You are running a development version ({SCRIPT_VERSION})", 'cyan')
            print_colored(f"  Latest stable release: {latest_version}", 'cyan')
            print()
        else:
            print_colored("  ✓   You are running the latest version", 'green')
            print()
    else:
        print_colored("  (Unable to check for updates - connection failed)", 'cyan')
        print_colored("  Visit https://techy-notes.com for the latest version", 'cyan')
        print()


def display_phase_banner(phase_num, phase_name):
    """Display a prominent phase banner with decorative borders"""
    print()
    print_colored("═" * 70, 'cyan')
    print_colored(f"║{f'PHASE {phase_num}':^68}║", 'cyan')
    print_colored(f"║{phase_name.upper():^68}║", 'cyan')
    print_colored(f"║{f'Script Version: {SCRIPT_VERSION}':^68}║", 'cyan')
    print_colored("═" * 70, 'cyan')
    print()


def print_phase_banner(phase_num, phase_name):
    """Print a phase banner with version information"""
    latest_version, status_text = check_latest_version()
    
    # Build the banner text
    banner_text = f"PHASE {phase_num}: {phase_name}"
    if status_text:
        banner_text += f" --------- Script Version {SCRIPT_VERSION} {status_text}"
    else:
        banner_text += f" --------- Script Version {SCRIPT_VERSION}"
    
    # Print with consistent borders (90 chars to handle longest possible banner)
    print("=" * 100)
    print(banner_text)
    print("=" * 100)


def obfuscate_sensitive(text):
    """
    Obfuscate sensitive strings by showing first 2 and last 2 characters
    """
    if len(text) <= 4:
        return text
    return text[:2] + '*' * (len(text) - 4) + text[-2:]


def filter_sensitive_output(text, hide_sensitive=False):
    """
    Filter and obfuscate sensitive information in output text
    """
    import re
    lines = text.split('\n')
    filtered_lines = []
    
    for line in lines:
        # Always skip Qt and Fontconfig error messages
        if 'QObject::startTimer: Timers can only be used with threads started with QThread' in line:
            continue
        if 'Fontconfig error: Cannot load default config file' in line:
            continue
        
        if not hide_sensitive:
            filtered_lines.append(line)
            continue
        
        filtered_line = line
        
        # Obfuscate DSN values
        if line.startswith('DSN '):
            dsn_value = line.replace('DSN ', '').strip()
            if dsn_value:
                filtered_line = f"DSN {obfuscate_sensitive(dsn_value)}"
        
        # Obfuscate Tokens
        elif line.startswith('Tokens '):
            tokens_value = line.replace('Tokens ', '').strip()
            if tokens_value:
                if ',' in tokens_value:
                    token_parts = tokens_value.split(',')
                    obfuscated_parts = [obfuscate_sensitive(part.strip()) for part in token_parts]
                    filtered_line = f"Tokens {','.join(obfuscated_parts)}"
                else:
                    filtered_line = f"Tokens {obfuscate_sensitive(tokens_value)}"
        
        # Obfuscate DRM key with UUID and secret key
        elif 'amzn1.drm-key.v1.' in line and '$secret_key:' in line:
            match = re.search(r'(amzn1\.drm-key\.v1\.)([a-f0-9\-]+)(\$secret_key:)([a-f0-9]+)', line)
            if match:
                prefix = match.group(1)
                uuid = match.group(2)
                middle = match.group(3)
                secret = match.group(4)
                
                obfuscated_uuid = obfuscate_sensitive(uuid)
                obfuscated_secret = obfuscate_sensitive(secret)
                
                original = f"{prefix}{uuid}{middle}{secret}"
                replacement = f"{prefix}{obfuscated_uuid}{middle}{obfuscated_secret}"
                filtered_line = line.replace(original, replacement)
        
        filtered_lines.append(filtered_line)
    
    return '\n'.join(filtered_lines)


def timed_input(prompt: str):
    """
    Wrapper around input() that also measures how long the user took to respond.

    Use this in place of input() wherever the script pauses for a user decision
    so that the elapsed 'processing time' (wall-clock minus user-wait time) can
    be calculated accurately at the end of the run.

    Returns:
        tuple: (response: str, seconds_waited: float)
            response        - The stripped string that input() returned.
            seconds_waited  - Floating-point seconds from prompt to Enter key.
    """
    t0 = time.time()
    response = input(prompt)
    return response, time.time() - t0


def timed_choice_input(prompt: str, valid_choices: list, default: str, timeout: int = 10) -> str:
    """
    Display a prompt with a live countdown timer. If the user does not press any
    key within ``timeout`` seconds the ``default`` choice is automatically applied.

    Pressing ANY key during the countdown cancels the timer so the user can
    type their full choice at the normal prompt and press Enter.

    Windows-only implementation (uses msvcrt for non-blocking key detection).
    Falls back to plain input() on non-Windows platforms.

    Args:
        prompt:        Prompt text shown when the timer is cancelled,
                       e.g. "Your choice (A/N/Q) [N]: "
        valid_choices: Uppercase single-character strings accepted as valid input,
                       e.g. ['A', 'N', 'Q']
        default:       Choice applied automatically on timeout or bare Enter.
                       Must be a member of valid_choices.
        timeout:       Seconds to wait before auto-applying default (default: 10).

    Returns:
        str: Selected choice (uppercase), guaranteed to be in valid_choices.
    """
    # Pre-assign to None so the name is always defined (satisfies Pylance).
    # A successful 'import msvcrt' below overwrites this with the real module.
    _msvcrt = None
    try:
        import msvcrt as _msvcrt  # type: ignore[no-redef]
    except ImportError:
        pass

    if _msvcrt is None:
        # Non-Windows fallback - plain blocking input
        while True:
            choice = input(prompt).strip().upper()
            if choice == '' or choice not in valid_choices:
                return default
            return choice

    # Windows path - _msvcrt is the real msvcrt module from here on.
    # Drain any keys that were already in the buffer before we started
    while _msvcrt.kbhit():
        _msvcrt.getch()

    deadline = time.time() + timeout
    cancelled = False

    while time.time() < deadline:
        remaining = max(1, int(deadline - time.time()) + 1)
        print(
            f"\r  Auto-selecting [{default}] in {remaining:2d}s"
            f" - press any key to choose manually...",
            end='', flush=True
        )
        if _msvcrt.kbhit():
            _msvcrt.getch()     # discard the triggering keypress
            cancelled = True
            break
        time.sleep(0.1)

    # Clear the countdown line
    print("\r" + " " * 70 + "\r", end='', flush=True)

    if not cancelled:
        # Timer expired - auto-apply default
        print(f"  No input received - automatically selecting [{default}]")
        print()
        return default

    # User cancelled the timer - accept normal input with validation loop
    print("  Timer cancelled - enter your choice:")
    while True:
        choice = input(prompt).strip().upper()
        if choice == '':
            return default
        if choice in valid_choices:
            return choice
        print(f"  Invalid choice. Please enter {'/'.join(valid_choices)}.")


def format_elapsed(seconds: float) -> str:
    """
    Format a duration in seconds as HH:MM:SS.

    Args:
        seconds: Duration in seconds (float or int).

    Returns:
        str: Zero-padded HH:MM:SS string, e.g. "01:20:22".
    """
    total_secs = int(seconds)
    hours, remainder = divmod(total_secs, 3600)
    minutes, secs = divmod(remainder, 60)
    return f"{hours:02d}:{minutes:02d}:{secs:02d}"


def display_progress_timer(message, timeout_seconds, timer_stopped_event):
    """Reusable progress timer display function"""
    start_time = time.time()
    while not timer_stopped_event.is_set():
        elapsed = int(time.time() - start_time)
        minutes, seconds = divmod(elapsed, 60)
        timeout_mins, timeout_secs = divmod(timeout_seconds, 60)
        print(f"\r  {message}.. [ {minutes:02d}:{seconds:02d} / {timeout_mins:02d}:{timeout_secs:02d} ]", 
              end='', flush=True)
        time.sleep(1)


def get_disk_space(path):
    """Get disk space information for a given path"""
    try:
        check_path = path
        while not os.path.exists(check_path) and check_path != os.path.dirname(check_path):
            check_path = os.path.dirname(check_path)
        
        if not os.path.exists(check_path):
            return None, None, None
        
        stat = shutil.disk_usage(check_path)
        free_gb = stat.free / (1024 ** 3)
        total_gb = stat.total / (1024 ** 3)
        percent_free = (stat.free / stat.total) * 100 if stat.total > 0 else 0
        
        return free_gb, total_gb, percent_free
    except Exception as e:
        return None, None, None


def test_kindle_folder_accessibility(kindle_content_path, working_dir=None):
    """
    Test if Kindle content folder is accessible by performing actual file operations.
    This helps detect access violations (0xc0000005) before attempting key extraction.
    
    Args:
        kindle_content_path: Path to Kindle content directory
        working_dir: Working directory for temp files (optional)
    
    Returns:
        tuple: (success: bool, error_message: str or None, test_file: str or None)
    """
    import tempfile
    
    temp_test_dir = None
    
    try:
        # Find a book file to test with
        test_book_file = None
        
        if not os.path.exists(kindle_content_path):
            return False, f"Kindle content path does not exist: {kindle_content_path}", None
        
        # Search for any book file (.azw, .kfx, etc.)
        for root, dirs, files in os.walk(kindle_content_path):
            for file in files:
                if file.lower().endswith(('.azw', '.kfx', '.kfx-zip', '.azw3', '.azw1', '.mobi')):
                    test_book_file = os.path.join(root, file)
                    break
            if test_book_file:
                break
        
        if not test_book_file:
            return False, "No book files found in Kindle content directory", None
        
        # Create temp directory for test
        if working_dir:
            temp_test_dir = os.path.join(working_dir, "temp_access_test")
        else:
            temp_test_dir = os.path.join(tempfile.gettempdir(), "kindle_access_test")
        
        os.makedirs(temp_test_dir, exist_ok=True)
        
        # Get file info
        file_name = os.path.basename(test_book_file)
        file_size = os.path.getsize(test_book_file)
        temp_copy_path = os.path.join(temp_test_dir, file_name)
        
        # Test 1: Copy file
        try:
            shutil.copy2(test_book_file, temp_copy_path)
        except (OSError, PermissionError) as e:
            # Cleanup and return error
            if os.path.exists(temp_test_dir):
                try:
                    shutil.rmtree(temp_test_dir)
                except:
                    pass
            return False, f"Cannot copy files from Kindle folder: {str(e)}", test_book_file
        
        # Test 2: Verify copy succeeded
        if not os.path.exists(temp_copy_path):
            # Cleanup and return error
            if os.path.exists(temp_test_dir):
                try:
                    shutil.rmtree(temp_test_dir)
                except:
                    pass
            return False, "Copy operation completed but file does not exist", test_book_file
        
        # Test 3: Verify file size matches
        copied_size = os.path.getsize(temp_copy_path)
        if copied_size != file_size:
            # Cleanup and return error
            if os.path.exists(temp_test_dir):
                try:
                    shutil.rmtree(temp_test_dir)
                except:
                    pass
            return False, f"Copied file size mismatch (original: {file_size}, copied: {copied_size})", test_book_file
        
        # Test 4: Delete temp copy
        try:
            os.remove(temp_copy_path)
        except (OSError, PermissionError) as e:
            # Cleanup and return error
            if os.path.exists(temp_test_dir):
                try:
                    shutil.rmtree(temp_test_dir)
                except:
                    pass
            return False, f"Cannot delete temporary files: {str(e)}", test_book_file
        
        # Test 5: Cleanup temp directory
        try:
            shutil.rmtree(temp_test_dir)
        except (OSError, PermissionError) as e:
            return False, f"Cannot remove temporary directory: {str(e)}", test_book_file
        
        # All tests passed
        return True, None, test_book_file
        
    except Exception as e:
        # Cleanup on exception
        if temp_test_dir and os.path.exists(temp_test_dir):
            try:
                shutil.rmtree(temp_test_dir)
            except:
                pass
        return False, f"Unexpected error during accessibility test: {str(e)}", None


def is_cloud_synced_location(directory):
    """Detect if directory is inside a cloud sync folder"""
    dir_lower = directory.lower()
    
    cloud_patterns = {
        'onedrive': ['onedrive', '\\onedrive\\', '/onedrive/'],
        'google-drive': ['google drive', '\\google drive\\', '/google drive/', 'googledrive'],
        'dropbox': ['dropbox', '\\dropbox\\', '/dropbox/'],
        'icloud': ['icloud', '\\icloud\\', '/icloud/', 'icloud drive'],
    }
    
    for service, patterns in cloud_patterns.items():
        for pattern in patterns:
            if pattern in dir_lower:
                return True, service
    
    return False, None


def get_kindle_version(kindle_exe_path):
    """
    Read the FileVersion of Kindle.exe using PowerShell's VersionInfo.

    Args:
        kindle_exe_path: Full path to Kindle.exe

    Returns:
        str or None: Version string (e.g. "2.8.2.70987") or None if unreadable.
    """
    if not kindle_exe_path or not os.path.exists(kindle_exe_path):
        return None
    try:
        escaped = kindle_exe_path.replace("'", "''")
        ps_cmd = f"(Get-Item '{escaped}').VersionInfo.FileVersion"
        result = subprocess.run(
            ['powershell', '-NoProfile', '-Command', ps_cmd],
            capture_output=True,
            text=True,
            timeout=10
        )
        if result.returncode == 0:
            version = result.stdout.strip()
            return version if version else None
        return None
    except Exception:
        return None


def check_kindle_version_supported(kindle_path):
    """
    Check whether the Kindle for PC installation is supported by either tool.

    KFXKeyExtractor supports: 2.8.0 / 2.8.1 / 2.8.2
    KFXArchiver283  supports: 2.8.0 / 2.8.1 / 2.8.2 / 2.8.3
    KFXArchiver291  supports: 2.9.1

    Both tools hard-code memory offsets. An unsupported version causes
    checkExecs() to dereference an invalid pointer and crash with
    STATUS_ACCESS_VIOLATION (0xC0000005 / exit code 3221225477).

    Key behaviour:
      - Kindle 2.8.3: KFXKeyExtractor FAILS, KFXArchiver WORKS.
        The script should automatically switch to alt method (KFXArchiver).
      - Kindle 2.8.4+: Both tools FAIL -> genuine CRITICAL error.

    Args:
        kindle_path: Path to the Kindle application directory (contains Kindle.exe)

    Returns:
        tuple: (primary_ok: bool, alt_ok: bool, version_str: str or None)
            primary_ok  - version works with KFXKeyExtractor
            alt_ok      - version works with KFXArchiver
            version_str - detected version string, or None if unreadable
    """
    if not kindle_path:
        return True, True, None  # Can't check - assume OK for both

    kindle_exe = os.path.join(kindle_path, 'Kindle.exe')
    if not os.path.exists(kindle_exe):
        return True, True, None  # Not found - handled elsewhere

    version = get_kindle_version(kindle_exe)
    if not version:
        return True, True, None  # Can't read version - don't block

    primary_ok = any(version.startswith(p) for p in SUPPORTED_KINDLE_VERSIONS)
    alt_ok = any(version.startswith(p) for p in SUPPORTED_KINDLE_VERSIONS_ALT)

    return primary_ok, alt_ok, version


def check_python_installation():
    """
    Detect if Python is running from the Microsoft Store sandbox.

    Microsoft Store Python runs in a virtualized filesystem environment.
    AppData writes are silently redirected to:
      AppData\\Local\\Packages\\PythonSoftwareFoundation.Python.*\\LocalCache\\Local\\

    This means the script CAN create folders and logs (they land in the
    virtualized path), but any native Win32 subprocess (e.g. KFXKeyExtractor)
    launched from sandboxed Python cannot perform cross-process memory access
    against non-sandboxed processes like Kindle.exe.  The extractor crashes
    with STATUS_ACCESS_VIOLATION (0xC0000005), leaves file handles open in
    temp_extraction, and cleanup subsequently fails.

    IMPORTANT - WindowsApps stubs vs real MS Store sandbox:
    --------------------------------------------------------
    Windows 11 places App Execution Alias stubs for ALL Python installs
    (including official python.org builds) under:
      C:\\Users\\<user>\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe

    These stubs are NOT sandboxed.  They are harmless redirect shims.
    A user who installed python.org Python, or who previously had MS Store
    Python and uninstalled it (leaving behind the dead stub), should NOT
    be flagged as running a Store Python.

    We distinguish real MS Store sandboxed Python from a mere stub by
    checking whether the corresponding Packages folder actually exists on
    disk.  If it does, the sandbox is active.  If it does not, the
    WindowsApps entry is just a stub pointing to a real python.org install.

    Returns:
        tuple:
            is_store (bool)  - True if running inside the MS Store sandbox
            exe_path (str)   - Full path to the current python.exe
            redirect_path (str or None)
                             - The virtualized LocalCache path when is_store
                               is True, otherwise None
    """
    exe_path = sys.executable
    exe_lower = exe_path.lower()

    # Case 1: exe_path directly contains Packages\PythonSoftwareFoundation
    # This is a definitive MS Store sandbox path.
    if 'packages' in exe_lower and 'pythonsoftwarefoundation' in exe_lower:
        redirect_path = None
        parts = exe_path.replace('/', '\\').split('\\')
        try:
            pkg_idx = next(
                i for i, p in enumerate(parts)
                if p.lower() == 'packages'
            )
            pkg_root = '\\'.join(parts[:pkg_idx + 2])
            redirect_path = os.path.join(pkg_root, 'LocalCache', 'Local')
        except (StopIteration, IndexError):
            redirect_path = None
        return True, exe_path, redirect_path

    # Case 2: exe_path is a WindowsApps stub (App Execution Alias).
    # These stubs exist for ALL Python installs on Windows 11 - they are NOT
    # automatically a sign of MS Store Python.  Only flag as Store Python if
    # the actual PythonSoftwareFoundation Packages folder is present on disk,
    # meaning the MS Store sandbox is genuinely installed and active.
    if 'microsoft\\windowsapps' in exe_lower and 'python' in exe_lower:
        user_home = os.path.expanduser("~")
        packages_root = os.path.join(
            user_home, 'AppData', 'Local', 'Packages'
        )
        store_pkg_found = False
        redirect_path = None

        if os.path.isdir(packages_root):
            try:
                for entry in os.listdir(packages_root):
                    if entry.lower().startswith('pythonsoftwarefoundation.python'):
                        pkg_root = os.path.join(packages_root, entry)
                        store_pkg_found = True
                        redirect_path = os.path.join(pkg_root, 'LocalCache', 'Local')
                        break
            except OSError:
                pass

        if store_pkg_found:
            # The MS Store sandbox package folder exists - this is a live
            # Store Python installation even though we entered via the stub.
            return True, exe_path, redirect_path
        else:
            # WindowsApps stub exists but no Packages folder - this is just
            # a leftover/harmless alias pointing to a real python.org install.
            # Do NOT flag as Store Python.
            return False, exe_path, None

    # Case 3: Normal python.org installation path - not a Store Python.
    return False, exe_path, None


def is_running_as_admin():
    """
    Check whether the current process has Windows administrator privileges.

    Uses ctypes to call the Windows API IsUserAnAdmin(), which returns True
    only when the process token carries the Administrators group SID and UAC
    elevation has been granted.

    Running WITHOUT admin rights is the most common cause of:
      - Permission denied writing to Program Files / Calibre plugin dirs
      - [Errno 13] on dedrm.json (Calibre holds a write lock)
      - Access violations reading Kindle installation in Program Files

    Returns:
        bool: True if elevated, False if standard user or on non-Windows.
    """
    try:
        import ctypes
        return ctypes.windll.shell32.IsUserAnAdmin() != 0
    except Exception:
        return False  # Can't determine - assume not elevated


def check_write_permissions(directory):
    """Check if directory is writable and not in cloud sync folder"""
    is_cloud, cloud_service = is_cloud_synced_location(directory)
    if is_cloud:
        return False, f"Cloud sync folder detected ({cloud_service}) - may cause access issues"
    
    test_dir = os.path.join(directory, ".write_test_temp_dir")
    try:
        os.makedirs(test_dir, exist_ok=True)
        os.rmdir(test_dir)
        return True, None
    except Exception as e:
        return False, str(e)


def rmtree_with_retry(path, max_retries=5, retry_delay=0.5):
    """Remove directory tree with retry logic for Windows file locking"""
    for attempt in range(max_retries):
        try:
            shutil.rmtree(path)
            return True
        except (OSError, PermissionError) as e:
            if attempt < max_retries - 1:
                time.sleep(retry_delay * (2 ** attempt))
            else:
                break
    
    # Fallback: Use Windows force-delete
    return force_delete_directory_windows(path, timeout=30)


def force_delete_directory_windows(path, timeout=30):
    """Force-delete directory using Windows rd command"""
    try:
        if not os.path.exists(path):
            return True
        
        cmd = ['cmd', '/c', 'rd', '/s', '/q', f'"{path}"']
        
        result = subprocess.run(
            cmd,
            capture_output=True,
            timeout=timeout,
            text=True,
            shell=True
        )
        
        return not os.path.exists(path)
        
    except subprocess.TimeoutExpired:
        return False
    except Exception:
        return False


def remove_file_with_retry(file_path, max_retries=3):
    """
    Delete a file with retry logic and force-delete fallback for Windows
    
    Args:
        file_path: Path to file to delete
        max_retries: Maximum number of retry attempts
        
    Returns:
        tuple: (success: bool, error_msg: str or None)
    """
    for attempt in range(max_retries):
        try:
            # First try: Normal file removal
            if os.path.exists(file_path):
                os.remove(file_path)
                
                # Verify deletion
                time.sleep(0.2)  # Brief wait for Windows filesystem
                if not os.path.exists(file_path):
                    return True, None
            else:
                return True, None  # File doesn't exist, success
                
        except PermissionError as e:
            if attempt < max_retries - 1:
                print_warn(f"File locked, retrying in 2 seconds... (attempt {attempt + 1}/{max_retries})")
                time.sleep(2)
            else:
                # Last attempt failed - try Windows force delete
                print_warn("Standard deletion failed - attempting force delete...")
                return force_delete_file_windows(file_path)
                
        except Exception as e:
            return False, f"Unexpected error during deletion: {e}"
    
    return False, "Unknown error during file deletion"


def force_delete_file_windows(file_path):
    """
    Force delete a file using Windows DEL command with force flags
    
    Args:
        file_path: Path to file to delete
        
    Returns:
        tuple: (success: bool, error_msg: str or None)
    """
    try:
        if not os.path.exists(file_path):
            return True, None
        
        # Use Windows DEL with force flag (/F = force delete read-only)
        cmd = ['cmd', '/c', 'del', '/F', '/Q', f'"{file_path}"']
        
        result = subprocess.run(
            cmd,
            capture_output=True,
            timeout=10,
            text=True,
            shell=True
        )
        
        # Verify deletion
        time.sleep(0.3)
        if not os.path.exists(file_path):
            return True, None
        else:
            return False, "File still exists after force delete"
            
    except subprocess.TimeoutExpired:
        return False, "Force delete command timed out"
    except Exception as e:
        return False, f"Force delete failed: {e}"


def is_kindle_running():
    """Check if Kindle.exe is currently running"""
    try:
        result = subprocess.run(
            ['tasklist', '/FI', 'IMAGENAME eq Kindle.exe'],
            capture_output=True,
            text=True,
            timeout=5
        )
        output_lower = result.stdout.lower()
        return 'kindle.exe' in output_lower
    except Exception:
        return True


# Calibre process names that indicate the application (or a helper that holds
# dedrm.json / the library database) is currently running.
# Kept as a module-level constant so both is_calibre_running() and
# find_calibre_processes() share the same list without duplication.
#
# calibre-debug.exe: The Calibre GUI launched in debug mode - holds the same
# database lock as calibre.exe. Added after confirming it was not detected.
_CALIBRE_PROCESS_NAMES = [
    'calibre.exe',
    'calibre-debug.exe',
    'calibre-parallel.exe',
    'calibre-server.exe',
    'calibredb.exe',
    'ebook-convert.exe',
    'ebook-viewer.exe',
    'ebook-edit.exe',
    'calibreportable.exe',
]

# Process names confirmed as phantoms/zombies during this script run.
# A phantom is a process that appears in tasklist but taskkill reports
# "There is no running instance" - it has no active handles and cannot
# hold any file or DB locks. Once confirmed, we exclude it from every
# subsequent check so Phase 2, Phase 3 etc. are not blocked by the
# same dead process entry.
_confirmed_phantom_processes: set = set()


def find_calibre_processes():
    """
    Return a list of Calibre-related process names that are ACTIVELY running.

    Excludes any processes previously confirmed as phantoms/zombies by the
    [K] Kill handler (stored in _confirmed_phantom_processes).  A phantom is
    a process that appears in tasklist but whose taskkill response is
    "There is no running instance" - it holds no locks and is harmless.

    Returns:
        list[str]: Found process names (empty list if none running or on error).
    """
    try:
        result = subprocess.run(
            ['tasklist', '/FI', 'STATUS eq RUNNING'],
            capture_output=True,
            text=True,
            timeout=5
        )
        if result.returncode != 0:
            return []
        output_lower = result.stdout.lower()
        return [
            name for name in _CALIBRE_PROCESS_NAMES
            if name in output_lower and name not in _confirmed_phantom_processes
        ]
    except Exception:
        return []


def is_calibre_running():
    """Check if any Calibre processes are currently running"""
    return bool(find_calibre_processes())


def wait_for_calibre_to_close(context_message="", calibre_import_enabled=True):
    """
    Wait for Calibre to close, or let user continue at their own risk.

    If Calibre is running the user is offered two choices:
        [W]ait  - poll until Calibre is closed (original behaviour)
        [C]ontinue - proceed immediately, accepting the risk

    When calibre_import_enabled is True the risk is database corruption
    (Phases 3 & 4 will be skipped by the caller).
    When calibre_import_enabled is False the risk is a permission error
    on file operations (Phases 3 & 4 are already skipped anyway).

    Args:
        context_message: Optional message explaining why Calibre needs to be closed
        calibre_import_enabled: Whether Calibre import is enabled in config

    Returns:
        dict with keys:
            proceed             (bool) - True = safe to proceed, False = user continued with risk
            calibre_was_running (bool) - True if Calibre was detected running when called
            wait_seconds        (int)  - Total seconds spent in the wait loop (0 if not running)
            action              (str)  - 'not_running' | 'waited' | 'killed' | 'continued' | 'quit'
            processes_found     (list) - Process name(s) detected (empty list if not running)
    """
    _call_start = time.time()
    running_procs = find_calibre_processes()
    if not running_procs:
        return {
            'proceed': True,
            'calibre_was_running': False,
            'wait_seconds': 0,
            'action': 'not_running',
            'processes_found': []
        }

    print()
    print_colored("=" * 70, "red")
    print_colored(" CALIBRE IS CURRENTLY RUNNING!", "red")
    print_colored("=" * 70, "red")
    print()

    # Show exactly which process(es) triggered the detection so the user
    # can verify this is not a false positive.
    print("  Detected process(es):")
    for proc in running_procs:
        print(f"    - {proc}")
    print()

    if context_message:
        print_colored(context_message, "yellow")
        print()

    if calibre_import_enabled:
        print_colored("WARNING: Continuing with Calibre open risks DATABASE CORRUPTION!", "red")
        print_colored("         If you continue, Phases 3 & 4 will be skipped to protect", "red")
        print_colored("         your Calibre library.", "red")
    else:
        print_colored("WARNING: Continuing with Calibre open may cause PERMISSION ERRORS", "yellow")
        print_colored("         on file operations. Phases 3 & 4 are already disabled,", "yellow")
        print_colored("         so the risk is limited to Phases 1 & 2.", "yellow")
    print()

    print("Options:")
    print("  [W] Wait     - Wait for Calibre to close (check every second)")
    print("  [K] Kill     - Force-terminate the detected Ghost/Zombie Calibre process(es) now")
    print("  [C] Continue - Proceed at my own risk (not recommended)")
    print("  [Q] Quit     - Exit the script now")
    print()

    while True:
        choice = input("Your choice (W/K/C/Q) [W]: ").strip().upper()
        if choice == '' or choice == 'W':
            break
        elif choice == 'K':
            print()
            print_step("Force-terminating detected Calibre process(es)...")
            _all_handled = True
            for proc in running_procs:
                try:
                    kill_result = subprocess.run(
                        ['taskkill', '/F', '/IM', proc],
                        capture_output=True,
                        text=True,
                        timeout=10
                    )
                    if kill_result.returncode == 0:
                        print_ok(f"Terminated: {proc}")
                    else:
                        combined = (kill_result.stderr + kill_result.stdout).lower()
                        if 'no running instance' in combined or 'not found' in combined:
                            # Windows says it has no active handles - it is a phantom/zombie.
                            # A phantom cannot hold file or DB locks, so it is safe to ignore.
                            # Add to the confirmed-phantom set so every subsequent call to
                            # find_calibre_processes() / is_calibre_running() skips this
                            # process name for the rest of this script run.
                            _confirmed_phantom_processes.add(proc)
                            print_ok(f"{proc} is a phantom/zombie process (no active handles)")
                            print(f"   Marked as phantom - will be ignored by all future checks.")
                        else:
                            err = (kill_result.stderr.strip() or kill_result.stdout.strip())
                            print_warn(f"Could not terminate {proc}: {err}")
                            _all_handled = False
                except Exception as _e:
                    print_warn(f"Error killing {proc}: {_e}")
                    _all_handled = False

            time.sleep(1)  # Give Windows a moment to clean up
            print()
            if _all_handled:
                print_ok("All Calibre processes handled - continuing...")
                print()
                return {
                    'proceed': True,
                    'calibre_was_running': True,
                    'wait_seconds': int(time.time() - _call_start),
                    'action': 'killed',
                    'processes_found': list(running_procs)
                }
            # Some processes genuinely resisted - re-check and give user options
            still_running = find_calibre_processes()
            if not still_running:
                print_ok("All Calibre processes are gone - continuing...")
                print()
                return {
                    'proceed': True,
                    'calibre_was_running': True,
                    'wait_seconds': int(time.time() - _call_start),
                    'action': 'killed',
                    'processes_found': list(running_procs)
                }
            print_warn("Some processes could not be terminated:")
            for p in still_running:
                print(f"    - {p}")
            print("   Try [C] to continue anyway, or [W] to wait.")
            print()
            running_procs = still_running
            continue
        elif choice == 'C':
            print()
            print_warn("Proceeding with Calibre still running - at user's own risk.")
            if calibre_import_enabled:
                print_warn("Phases 3 & 4 will be SKIPPED to prevent database corruption.")
            print()
            return {
                'proceed': False,
                'calibre_was_running': True,
                'wait_seconds': int(time.time() - _call_start),
                'action': 'continued',
                'processes_found': list(running_procs)
            }
        elif choice == 'Q':
            print()
            print_warn("Exiting script at user's request.")
            print()
            sys.exit(0)
        else:
            print_error("Invalid choice. Please enter W, K, C, or Q.")

    # User chose to wait - poll until Calibre closes
    print()
    print_step("Please close Calibre now...")
    print("(The script will automatically continue once Calibre is closed)")
    print()

    _wait_start = time.time()
    dots = 0
    while is_calibre_running():
        dots = (dots + 1) % 4
        elapsed_secs = int(time.time() - _wait_start)
        elapsed_min, elapsed_sec = divmod(elapsed_secs, 60)
        print(f"\r  Waiting for Calibre to close{'.' * dots}  [{elapsed_min:02d}:{elapsed_sec:02d}]   ",
              end='', flush=True)
        time.sleep(1)

    _wait_seconds = int(time.time() - _wait_start)

    # Clear the waiting message
    print("\r" + " " * 60 + "\r", end='', flush=True)
    wait_min, wait_sec = divmod(_wait_seconds, 60)
    wait_hr, wait_min = divmod(wait_min, 60)
    print_ok(f"Calibre has been closed - continuing... (waited {wait_hr:02d}:{wait_min:02d}:{wait_sec:02d})")
    print()

    return {
        'proceed': True,
        'calibre_was_running': True,
        'wait_seconds': _wait_seconds,
        'action': 'waited',
        'processes_found': list(running_procs)
    }


def check_calibre_installed():
    """Check if Calibre is installed"""
    try:
        result = subprocess.run(
            ['calibredb', '--version'],
            capture_output=True,
            timeout=5
        )
        return result.returncode == 0
    except:
        return False


def find_tool_by_prefix(search_dirs, prefix):
    """
    Locate a tool executable by case-insensitive filename prefix.

    Scans each directory in search_dirs (in order) for a file whose lowercased
    name starts with prefix and ends with '.exe'.  Returns the full path of the
    first match found, or None if no match exists in any of the directories.

    Args:
        search_dirs: Ordered list of directory paths to search.
        prefix:      Lowercase prefix string (e.g. 'kfxkeyextractor').

    Returns:
        str or None: Full path to the matched executable, or None.
    """
    for directory in search_dirs:
        if not os.path.isdir(directory):
            continue
        try:
            for fname in os.listdir(directory):
                if fname.lower().startswith(prefix) and fname.lower().endswith('.exe'):
                    return os.path.join(directory, fname)
        except OSError:
            continue
    return None


def check_extractor_exists(script_dir):
    """
    Locate the KFXKeyExtractor tool (any version) using case-insensitive prefix search.

    Search order:
      1. code/tools/  (canonical location for all KFX tools)
      2. script root  (project root, for backward compatibility)

    Returns:
        tuple: (found: bool, path: str or None)
    """
    tools_dir = os.path.join(script_dir, "code", "tools")
    search_dirs = [tools_dir, script_dir]
    path = find_tool_by_prefix(search_dirs, KFXKEYEXTRACTOR_PREFIX)
    return (path is not None, path)


def check_alt_extractor_exists(script_dir):
    """
    Locate the KFXArchiver tool (any version) using case-insensitive prefix search.

    Search order:
      1. code/tools/  (canonical location for all KFX tools)
      2. script root  (project root, for backward compatibility)

    Returns:
        tuple: (found: bool, path: str or None)
    """
    tools_dir = os.path.join(script_dir, "code", "tools")
    search_dirs = [tools_dir, script_dir]
    path = find_tool_by_prefix(search_dirs, KFXARCHIVER_PREFIX)
    return (path is not None, path)


def find_alt_extractor_for_version(script_dir, kindle_version):
    """
    Select the correct KFXArchiver executable for the detected Kindle version.

    Iterates KFXARCHIVER_TOOL_MAP and returns the path to the first tool whose
    supported version list contains a prefix that matches kindle_version.

    This allows the script to automatically pick:
      - KFXArchiver283.exe  for Kindle 2.8.3
      - KFXArchiver291.exe  for Kindle 2.9.1
      (and any future tool added to KFXARCHIVER_TOOL_MAP)

    If kindle_version is None or empty, falls back to returning any KFXArchiver
    found on disk (same behaviour as check_alt_extractor_exists).

    Args:
        script_dir:      Root directory of the script (contains code/tools/).
        kindle_version:  Version string returned by get_kindle_version(), e.g.
                         "2.8.3.70987" or "2.9.1.70999".  May be None.

    Returns:
        tuple: (found: bool, path: str or None)
            found - True if the version-matched tool exists on disk.
            path  - Full path to the matched executable, or None.
    """
    tools_dir = os.path.join(script_dir, "code", "tools")
    search_dirs = [tools_dir, script_dir]

    # If no version is known, fall back to any available KFXArchiver
    if not kindle_version:
        path = find_tool_by_prefix(search_dirs, KFXARCHIVER_PREFIX)
        return (path is not None, path)

    # Walk the tool map and find the first entry whose version prefixes match
    for version_prefixes, tool_prefix in KFXARCHIVER_TOOL_MAP:
        if any(kindle_version.startswith(vp) for vp in version_prefixes):
            path = find_tool_by_prefix(search_dirs, tool_prefix)
            return (path is not None, path)

    # No entry in the map matched this Kindle version - version is unsupported
    return (False, None)


def get_calibre_plugins_dir():
    """Get Calibre plugins directory path"""
    user_home = os.path.expanduser("~")
    return os.path.join(user_home, "AppData", "Roaming", "calibre", "plugins")


def check_plugin_installed(plugin_name):
    """Check if a Calibre plugin is installed"""
    plugins_dir = get_calibre_plugins_dir()
    plugin_path = os.path.join(plugins_dir, f"{plugin_name}.zip")
    return os.path.exists(plugin_path)


def generate_run_id(config=None):
    """
    Generate a unique Run ID for this script execution and persist it to config.

    The Run ID is embedded in the filename of every raw debug log produced
    during the run (extraction, import, conversion), making it trivial to
    group all three logs that belong to the same execution:

        raw_extraction_RUN_20260329_182154.log
        raw_import_RUN_20260329_182154.log
        raw_conversion_RUN_20260329_182154.log

    The ID is also written into startup.log and into every raw log header so
    a user can search for it across all files.

    The function saves the generated ID to config['_runtime_run_id'] so that
    get_raw_log_path() can read it without needing it passed explicitly.

    Args:
        config: Loaded config dict to persist the Run ID into (optional).
                When None the ID is generated and returned but not saved.

    Returns:
        str: Run ID string, e.g. "RUN_20260329_182154"
    """
    run_id = "RUN_" + datetime.now().strftime("%Y%m%d_%H%M%S")

    if config is not None:
        config['_runtime_run_id'] = run_id
        # Persist to disk so every phase can read it via load_config()
        try:
            from modules.config import save_config as _save_cfg  # type: ignore
            _save_cfg(config)
        except Exception:
            pass  # Non-fatal - ID is still in the in-memory dict

    return run_id


def get_raw_log_path(working_dir, phase_name):
    """
    Return the full path for a RAW debug log file for the given phase.

    Filename format (with Run ID):
        raw_<phase>_RUN_YYYYMMDD_HHMMSS.log
    Fallback (no Run ID in config):
        raw_<phase>_YYYYMMDD_HHMMSS.log

    The Run ID is read from config['_runtime_run_id'] which is written by
    generate_run_id() at startup.  All three phase logs produced in the same
    script run will share the same Run ID, making them easy to group.
    """
    # Map phase names to numbered folders
    phase_folder_map = {
        'extraction': '01_Extraction',
        'import': '02_Import',
        'conversion': '03_Conversion'
    }

    folder_name = phase_folder_map.get(phase_name, f"{phase_name}_logs")
    logs_dir = os.path.join(working_dir, "Logs", folder_name)
    os.makedirs(logs_dir, exist_ok=True)

    # Try to read the Run ID saved by generate_run_id()
    run_id = None
    try:
        from modules.config import load_config as _load_cfg  # type: ignore
        _cfg = _load_cfg()
        if _cfg:
            run_id = _cfg.get('_runtime_run_id')
    except Exception:
        pass

    suffix = run_id if run_id else datetime.now().strftime("%Y%m%d_%H%M%S")
    return os.path.join(logs_dir, f"raw_{phase_name}_{suffix}.log")


def write_raw_log_header(raw_log_path, kindle_version=None, extraction_mode=None,
                         calibre_status=None):
    """
    Write the header block for a RAW debug log file.

    Must be called BEFORE the first append_to_raw_log() invocation so the
    header includes Kindle version and extraction mode context.
    append_to_raw_log() already skips header creation when the file exists,
    so calling this first integrates transparently with the existing flow.

    When kindle_version or extraction_mode are not supplied, the function
    automatically reads them from the runtime fields saved to
    key_finder_config.json at startup:
        _runtime_kindle_version  - Kindle for PC version detected this run
        _runtime_extraction_mode - "Mode A - KFXKeyExtractor (Standard)" or
                                   "Mode B - KFXArchiver Only (Alternate Method)"

    Args:
        raw_log_path:     Full path to the log file (created by get_raw_log_path).
        kindle_version:   Detected Kindle for PC version string, or None to
                          auto-read from config.
        extraction_mode:  Human-readable mode label, or None to auto-read
                          from config.
        calibre_status:   Optional dict returned by wait_for_calibre_to_close().
                          When supplied, a [CALIBRE STATUS AT PHASE START] block
                          is written into the header showing whether Calibre was
                          open when this phase started and how long the script waited.
    """
    try:
        # Auto-read runtime fields from config when not explicitly supplied.
        # This lets every phase call write_raw_log_header(raw_log_path) with
        # no extra arguments - they all get the same contextual header.
        if kindle_version is None or extraction_mode is None:
            try:
                from modules.config import load_config as _load_cfg  # type: ignore
                _cfg = _load_cfg()
                if _cfg:
                    if kindle_version is None:
                        kindle_version = _cfg.get('_runtime_kindle_version')
                    if extraction_mode is None:
                        extraction_mode = _cfg.get('_runtime_extraction_mode')
            except Exception:
                pass  # Config unreadable - fall through to Unknown defaults

        os.makedirs(os.path.dirname(raw_log_path), exist_ok=True)

        # Derive a readable phase label from the parent folder name.
        # e.g.  "01_Extraction" -> "01_EXTRACTION"
        phase_label = os.path.basename(os.path.dirname(raw_log_path)).upper()

        # Also read Run ID from config for the header
        run_id = None
        try:
            from modules.config import load_config as _load_cfg2  # type: ignore
            _cfg2 = _load_cfg2()
            if _cfg2:
                run_id = _cfg2.get('_runtime_run_id')
        except Exception:
            pass

        with open(raw_log_path, 'w', encoding='utf-8') as f:
            f.write("═" * 70 + "\n")
            f.write(f"RAW DEBUG LOG - {phase_label} PHASE\n")
            f.write("═" * 70 + "\n")
            f.write(f"Script Version:    {SCRIPT_VERSION}\n")
            f.write(f"Satsuoni's Tools:  {SATSUONI_TOOLS_VERSION}\n")
            f.write(f"Log Created:       {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
            if run_id:
                f.write(f"Run ID:            {run_id}\n")
            f.write(f"Platform:          {platform.platform()}\n")
            f.write(f"Python:            {sys.version.split()[0]}\n")
            _is_admin = is_running_as_admin()
            _admin_str = "YES (elevated)" if _is_admin else "NO  (standard user)"
            f.write(f"Admin Rights:      {_admin_str}\n")

            if kindle_version and kindle_version != "Unknown":
                f.write(f"Kindle Version:    {kindle_version}\n")
            else:
                f.write(
                    "Kindle Version:    Unknown "
                    "(version check failed - PowerShell unavailable or exe not found)\n"
                )

            if extraction_mode:
                f.write(f"Extraction Mode:   {extraction_mode}\n")
            else:
                f.write("Extraction Mode:   Unknown\n")

            # ----------------------------------------------------------------
            # Calibre status block - only written when status is provided
            # ----------------------------------------------------------------
            if calibre_status is not None:
                was_running = calibre_status.get('calibre_was_running', False)
                action = calibre_status.get('action', 'not_running')
                wait_secs = calibre_status.get('wait_seconds', 0)
                procs = ', '.join(calibre_status.get('processes_found', [])) or 'none'

                f.write("\n")
                f.write("[CALIBRE STATUS AT PHASE START]\n")
                if not was_running:
                    f.write("  Calibre was running      : NO - not detected at phase start\n")
                else:
                    wait_hr, rem = divmod(wait_secs, 3600)
                    wait_min, wait_sec = divmod(rem, 60)
                    action_labels = {
                        'waited':    '[W] Waited - script polled until Calibre closed',
                        'killed':    '[K] Killed - Calibre process(es) force-terminated',
                        'continued': '[C] Continued - user proceeded with Calibre still open',
                    }
                    action_str = action_labels.get(action, f'[?] {action}')
                    f.write("  Calibre was running      : YES - detected when phase started\n")
                    f.write(f"  Detected process(es)     : {procs}\n")
                    f.write(f"  Action taken             : {action_str}\n")
                    f.write(f"  Time waited              : {wait_hr:02d}:{wait_min:02d}:{wait_sec:02d}"
                            f"  ({wait_secs}s)\n")
                f.write("\n")

            f.write("═" * 70 + "\n\n")

    except Exception:
        pass


def append_to_raw_log(raw_log_path, command, stdout, stderr, exit_code, book_info=None):
    """Append subprocess results to RAW debug log file"""
    try:
        file_exists = os.path.exists(raw_log_path)
        
        with open(raw_log_path, 'a', encoding='utf-8') as f:
            if not file_exists:
                f.write("═" * 70 + "\n")
                phase_name = os.path.basename(os.path.dirname(raw_log_path)).replace('_logs', '').upper()
                f.write(f"RAW DEBUG LOG - {phase_name} PHASE\n")
                f.write("═" * 70 + "\n")
                f.write(f"Script Version:    {SCRIPT_VERSION}\n")
                f.write(f"Satsuoni's Tools:  {SATSUONI_TOOLS_VERSION}\n")
                f.write(f"Log Created:       {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                f.write(f"Platform:          {platform.platform()}\n")
                f.write(f"Python:            {sys.version.split()[0]}\n")
                _fb_is_admin = is_running_as_admin()
                _fb_admin_str = "YES (elevated)" if _fb_is_admin else "NO  (standard user)"
                f.write(f"Admin Rights:      {_fb_admin_str}\n")
                f.write("═" * 70 + "\n\n")
            
            f.write("─" * 70 + "\n")
            f.write(f"[{datetime.now().strftime('%H:%M:%S')}]")
            if book_info:
                f.write(f" Book: {book_info}\n")
            else:
                f.write("\n")
            
            if isinstance(command, list):
                f.write(f"Command: {' '.join(command)}\n")
            else:
                f.write(f"Command: {command}\n")
            
            f.write("─" * 70 + "\n")
            f.write("STDOUT:\n")
            f.write(stdout if stdout else "(empty)\n")
            f.write("\n")
            f.write("STDERR:\n")
            f.write(stderr if stderr else "(empty)\n")
            f.write("\n")
            f.write(f"Exit Code: {exit_code}\n")
            f.write("═" * 70 + "\n\n")
            
    except Exception:
        pass


def cleanup_old_raw_logs(logs_dir, keep_count=10):
    """Cleanup old RAW log files"""
    try:
        if not os.path.exists(logs_dir):
            return
        
        raw_logs = []
        for filename in os.listdir(logs_dir):
            if filename.startswith('raw_') and filename.endswith('.log'):
                filepath = os.path.join(logs_dir, filename)
                mtime = os.path.getmtime(filepath)
                raw_logs.append((filepath, mtime))
        
        raw_logs.sort(key=lambda x: x[1], reverse=True)
        
        for filepath, _ in raw_logs[keep_count:]:
            try:
                os.remove(filepath)
            except Exception:
                pass
                
    except Exception:
        pass


def get_calibre_plugin_details():
    """
    Read installed Calibre plugin versions and sub-component presence from the
    plugin zip files in the Calibre configuration directory (%APPDATA%\\calibre\\plugins\\).

    Returns a dict with keys:
        dedrm_version       (str)  - Installed DeDRM version string (e.g. "v10.0.20")
                                     or "Unknown" if not found / not parseable.
        kfx_version         (str)  - Installed KFX Input version string (e.g. "2.30.0")
                                     or "Unknown".
        kfx_metadata_reader (bool) - True if metadata_reader.py is present inside
                                     KFX Input.zip (meaning the sub-plugin is registered
                                     and will be called during calibredb add).
    """
    results = {
        'dedrm_version':       'Unknown',
        'kfx_version':         'Unknown',
        'kfx_metadata_reader': False,
    }

    try:
        plugins_dir = os.path.join(os.environ.get('APPDATA', ''), 'calibre', 'plugins')

        # --- DeDRM version ---
        # Version is stored in __version.py (not __init__.py) as:
        #   __version__ = '10.0.18'
        # With a fallback to __init__.py for older plugin layouts.
        for dedrm_name in ('DeDRM_plugin.zip', 'DeDRM.zip'):
            dedrm_zip = os.path.join(plugins_dir, dedrm_name)
            if os.path.exists(dedrm_zip):
                try:
                    with zipfile.ZipFile(dedrm_zip, 'r') as z:
                        zip_names = z.namelist()

                        # Primary: read __version.py
                        version_file = next(
                            (n for n in zip_names if n.lower() == '__version.py'), None
                        )
                        # Fallback: __init__.py
                        if version_file is None:
                            version_file = next(
                                (n for n in zip_names if n.lower() == '__init__.py'), None
                            )

                        if version_file:
                            with z.open(version_file) as fh:
                                content = fh.read().decode('utf-8', errors='replace')

                            # Pattern 1: __version__ = '10.0.18'
                            m = re.search(r'__version__\s*=\s*["\']([0-9][^"\']+)["\']', content)
                            if m:
                                results['dedrm_version'] = f"v{m.group(1)}"
                            else:
                                # Pattern 2: PLUGIN_VERSION = "10.0.18"  (string, not tuple)
                                m = re.search(
                                    r'PLUGIN_VERSION\s*=\s*["\']([0-9][^"\']+)["\']', content
                                )
                                if m:
                                    results['dedrm_version'] = f"v{m.group(1)}"
                                else:
                                    # Pattern 3: PLUGIN_VERSION_STR = "v10.0.20"
                                    m = re.search(
                                        r'PLUGIN_VERSION_STR\s*=\s*["\']([^"\']+)["\']', content
                                    )
                                    if m:
                                        ver = m.group(1)
                                        results['dedrm_version'] = (
                                            ver if ver.startswith('v') else f"v{ver}"
                                        )
                                    else:
                                        # Pattern 4: tuple  PLUGIN_VERSION = (10, 0, 20)
                                        m = re.search(
                                            r'PLUGIN_VERSION\s*=\s*\((\d+),\s*(\d+),\s*(\d+)\)',
                                            content
                                        )
                                        if m:
                                            results['dedrm_version'] = (
                                                f"v{m.group(1)}.{m.group(2)}.{m.group(3)}"
                                            )
                except Exception:
                    pass
                break  # Stop after first DeDRM zip found

        # --- KFX Input version + KFX Metadata Reader presence ---
        kfx_zip_path = os.path.join(plugins_dir, 'KFX Input.zip')
        if os.path.exists(kfx_zip_path):
            try:
                with zipfile.ZipFile(kfx_zip_path, 'r') as z:
                    names = z.namelist()
                    # metadata_reader presence check
                    results['kfx_metadata_reader'] = any(
                        'metadata_reader' in n.lower() for n in names
                    )
                    # Version from __init__.py: version = (2, 30, 0)
                    try:
                        with z.open('__init__.py') as fh:
                            content = fh.read().decode('utf-8', errors='replace')
                        m = re.search(
                            r'version\s*=\s*\((\d+),\s*(\d+),\s*(\d+)\)', content
                        )
                        if m:
                            results['kfx_version'] = (
                                f"{m.group(1)}.{m.group(2)}.{m.group(3)}"
                            )
                    except Exception:
                        pass
            except Exception:
                pass

    except Exception:
        pass

    return results


def write_startup_log(working_dir, validation_report, config=None, config_path=None):
    """
    Write a startup.log to <working_dir>/Logs/ capturing every pre-flight
    check result.  A fresh log is written on every run (not appended) so the
    most recent file always reflects the latest execution.

    The log records:
      - Script / tool versions
      - Timestamp and OS info
      - Every field in the validation_report dict
      - Kindle version detection outcome and which method was selected
      - Warnings (both CRITICAL and WARNING severity)
      - Active configuration paths

    Args:
        working_dir:       Working directory (Logs/ sub-folder will be created).
        validation_report: Dict returned by validate_all_requirements().
        config:            Loaded config dict (optional, for extra path context).
        config_path:       Path to the config file that was loaded (optional).
    """
    try:
        logs_dir = os.path.join(working_dir, "Logs")
        os.makedirs(logs_dir, exist_ok=True)
        log_path = os.path.join(logs_dir, "startup.log")

        run_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        with open(log_path, 'w', encoding='utf-8') as f:
            # ----------------------------------------------------------------
            # Header
            # ----------------------------------------------------------------
            f.write("=" * 70 + "\n")
            f.write("KINDLE KEY FINDER - STARTUP / PRE-FLIGHT LOG\n")
            f.write("=" * 70 + "\n")
            f.write(f"Run timestamp : {run_ts}\n")
            # Include Run ID if already generated (it may not be yet on first call)
            _run_id = config.get('_runtime_run_id') if config else None
            if _run_id:
                f.write(f"Run ID        : {_run_id}\n")
            f.write(f"Script version: {SCRIPT_VERSION}\n")
            f.write(f"Satsuoni Tools: {SATSUONI_TOOLS_VERSION}\n")
            f.write(f"Platform      : {platform.platform()}\n")
            f.write(f"Python        : {sys.version.split()[0]}\n")
            is_admin = validation_report.get('is_admin', None)
            if is_admin is True:
                admin_str = "YES (elevated)"
            elif is_admin is False:
                admin_str = "NO  (standard user) <-- common cause of [Errno 13] permission errors"
            else:
                admin_str = "UNKNOWN"
            f.write(f"Admin rights  : {admin_str}\n")
            f.write("=" * 70 + "\n\n")

            # ----------------------------------------------------------------
            # Python environment
            # ----------------------------------------------------------------
            py_is_store, py_exe, py_redirect = check_python_installation()
            f.write("[PYTHON ENVIRONMENT]\n")
            f.write(f"  Executable        : {py_exe}\n")
            f.write(f"  Version           : {sys.version.split()[0]}\n")
            if py_is_store:
                f.write("  Microsoft Store   : YES  <-- WARNING\n")
                f.write("  Sandbox status    : SANDBOXED - file operations redirected to virtual path\n")
                if py_redirect:
                    f.write(f"  Redirect path     : {py_redirect}\n")
                f.write("  Impact            : Sandboxed process cannot access Kindle.exe memory\n")
                f.write("                      Extraction will fail (STATUS_ACCESS_VIOLATION)\n")
                f.write("                      Cleanup will fail (extractor leaves locked file handles)\n")
                f.write("  Fix               : Uninstall MS Store Python -> install from python.org\n")
            else:
                f.write("  Microsoft Store   : NO\n")
                f.write("  Sandbox status    : OK - standard installation\n")
            f.write("\n")

            # ----------------------------------------------------------------
            # Paths
            # ----------------------------------------------------------------
            f.write("[PATHS]\n")
            f.write(f"  Working directory : {validation_report.get('working_dir', 'N/A')}\n")
            f.write(f"  Fallback mode     : {validation_report.get('is_fallback', False)}\n")
            if validation_report.get('is_fallback'):
                f.write(f"  Fallback reason   : {validation_report.get('fallback_reason', 'N/A')}\n")
            f.write(f"  Config file       : {config_path if config_path else 'N/A'}\n")
            if config:
                f.write(f"  Always use AppData: {'YES' if config.get('always_use_appdata') else 'NO (auto-detect)'}\n")
                f.write(f"  Kindle content    : {config.get('kindle_content_path', 'N/A')}\n")
                calibre_import = config.get('calibre_import', {})
                if isinstance(calibre_import, dict):
                    f.write(f"  Calibre library   : {calibre_import.get('library_path', 'N/A')}\n")
                    f.write(f"  Calibre import    : {'enabled' if calibre_import.get('enabled') else 'disabled'}\n")
                    f.write(f"  Convert to EPUB   : {'enabled' if calibre_import.get('convert_to_epub') else 'disabled'}\n")
                    kfx_zip_raw = calibre_import.get('kfx_zip_mode', 'N/A')
                    kfx_zip_display = {
                        'delete_kfx_zip': 'Delete KFX-ZIP after import',
                        'keep': 'Keep KFX-ZIP files',
                        'convert_all': 'Convert all to EPUB'
                    }.get(kfx_zip_raw, kfx_zip_raw)
                    f.write(f"  KFX-ZIP mode      : {kfx_zip_display}\n")
                    src_mgmt_raw = calibre_import.get('source_file_management', 'N/A')
                    src_mgmt_display = {
                        'delete_originals': 'Delete originals after import',
                        'keep_both': 'Keep both files'
                    }.get(src_mgmt_raw, src_mgmt_raw)
                    f.write(f"  Source file mgmt  : {src_mgmt_display}\n")
                    gen_pdf = calibre_import.get('generate_pdf_for_comics', False)
                    f.write(f"  PDF for Comics    : {'enabled' if gen_pdf else 'disabled'}\n")
                f.write(f"  Extracted books   : {config.get('extracted_books_path', 'N/A')}\n")
            f.write("\n")

            # ----------------------------------------------------------------
            # General settings
            # ----------------------------------------------------------------
            if config:
                f.write("[SETTINGS]\n")
                f.write(f"  Hide sensitive    : {'YES' if config.get('hide_sensitive_info') else 'NO'}\n")
                f.write(f"  Raw debug logs    : {'enabled' if config.get('enable_raw_logs') else 'disabled'}\n")
                f.write(f"  Auto-launch Kindle: {'YES' if config.get('auto_launch_kindle') else 'NO'}\n")
                f.write(f"  Skip phase pauses : {'YES' if config.get('skip_phase_pauses') else 'NO'}\n")
                f.write(f"  Clear scr/phases  : {'YES' if config.get('clear_screen_between_phases') else 'NO'}\n")
                # Debug flags - only log when active to keep log clean
                if config.get('debug_force_alt_method'):
                    f.write("  debug_force_alt   : TRUE  <-- DEBUG FLAG ACTIVE\n")
                if config.get('debug_force_alt_retry'):
                    f.write("  debug_force_retry : TRUE  <-- DEBUG FLAG ACTIVE\n")
                f.write("\n")

            # ----------------------------------------------------------------
            # Launcher settings (flag files written by Run_keyfinder.bat)
            # ----------------------------------------------------------------
            f.write("[LAUNCHER SETTINGS]\n")
            # Derive the launcher directory from key_finder.py's own location
            # (sys.argv[0]) rather than the module-level script_dir, which
            # resolves to utils.py's parent and diverges when the script is
            # run from a development repo while flags live in the installed copy.
            _launcher_dir      = os.path.dirname(os.path.abspath(sys.argv[0]))
            f.write(f"  Launcher dir      : {_launcher_dir}\n")
            _no_shortcut_path  = os.path.join(_launcher_dir, ".no_shortcut")
            _window_mode_path  = os.path.join(_launcher_dir, ".window_mode")
            _desktop_shortcut  = os.path.join(
                os.path.expanduser("~"), "Desktop", "Kindle Key Finder.lnk"
            )

            # Desktop shortcut
            if os.path.exists(_desktop_shortcut):
                f.write("  Desktop shortcut  : EXISTS\n")
            elif os.path.exists(_no_shortcut_path):
                f.write("  Desktop shortcut  : NOT CREATED  (user declined - .no_shortcut flag set)\n")
            else:
                f.write("  Desktop shortcut  : NOT CREATED  (no flag set - offer not yet shown or shortcut was deleted)\n")

            # No-shortcut flag
            f.write(f"  .no_shortcut flag : {'SET' if os.path.exists(_no_shortcut_path) else 'NOT set'}\n")

            # Window mode flag
            if os.path.exists(_window_mode_path):
                try:
                    with open(_window_mode_path, 'r', encoding='utf-8') as _wm:
                        _wm_val = _wm.read().strip()
                except Exception:
                    _wm_val = "(unreadable)"
                _wm_label = {
                    '/MAX':    'Maximized (full screen)',
                    '/NORMAL': 'Normal window',
                }.get(_wm_val.upper(), _wm_val)
                f.write(f"  .window_mode flag : SET -> {_wm_label}\n")
            else:
                f.write("  .window_mode flag : NOT set  (preference not yet chosen)\n")
            f.write("\n")

            # ----------------------------------------------------------------
            # Tool discovery
            # ----------------------------------------------------------------
            f.write("[TOOLS]\n")
            ext_path = validation_report.get('extractor_path')
            alt_path = validation_report.get('alt_extractor_path')
            f.write(f"  KFXKeyExtractor   : {'FOUND' if validation_report.get('extractor_installed') else 'MISSING'}")
            f.write(f"  -> {ext_path if ext_path else 'not found'}\n")
            f.write(f"  KFXArchiver       : {'FOUND' if validation_report.get('alt_extractor_installed') else 'MISSING'}")
            f.write(f"  -> {alt_path if alt_path else 'not found'}\n")
            f.write("\n")

            # ----------------------------------------------------------------
            # Kindle for PC
            # ----------------------------------------------------------------
            f.write("[KINDLE FOR PC]\n")
            kindle_path = validation_report.get('kindle_path')
            kindle_installed = validation_report.get('kindle_installed', False)
            kindle_version = validation_report.get('kindle_version')
            primary_ok = validation_report.get('kindle_version_supported', True)
            alt_ok = validation_report.get('kindle_version_alt_ok', True)
            auto_alt = validation_report.get('kindle_version_auto_alt', False)

            f.write(f"  Installed         : {'YES' if kindle_installed else 'NO'}\n")
            f.write(f"  Location          : {kindle_path if kindle_path else 'N/A'}\n")

            if kindle_version:
                f.write(f"  Detected version  : {kindle_version}\n")
                f.write(f"  KFXKeyExtractor   : {'SUPPORTED' if primary_ok else 'UNSUPPORTED'}"
                        f"  (supports 2.8.0 / 2.8.1 / 2.8.2)\n")
                f.write(f"  KFXArchiver283    : {'SUPPORTED' if alt_ok else 'UNSUPPORTED'}"
                        f"  (supports 2.8.0 / 2.8.1 / 2.8.2 / 2.8.3)\n")
                f.write(f"  KFXArchiver291    : {'SUPPORTED' if alt_ok else 'UNSUPPORTED'}"
                        f"  (supports 2.9.1)\n")
                # Identify which specific archiver was selected for this K4PC version
                _sel_path = validation_report.get('alt_extractor_path')
                _sel_name = os.path.basename(_sel_path) if _sel_path else None
                if not primary_ok and alt_ok:
                    _sel_str = f" ({_sel_name})" if _sel_name else ""
                    f.write(f"  ACTION            : Auto-switched to ALT METHOD{_sel_str} "
                            f"- KFXKeyExtractor does not support {kindle_version}\n")
                elif not primary_ok and not alt_ok:
                    f.write("  ACTION            : CRITICAL - Kindle version unsupported by all tools\n")
                else:
                    _sel_str = f" | version-matched archiver: {_sel_name}" if _sel_name else ""
                    f.write(f"  ACTION            : Standard method (KFXKeyExtractor) selected{_sel_str}\n")
            else:
                f.write("  Detected version  : Could not read (PowerShell query failed or exe absent)\n")
                f.write("  Version check     : Skipped - assuming compatible\n")
            f.write("\n")

            # ----------------------------------------------------------------
            # Calibre / plugins
            # ----------------------------------------------------------------
            plugin_details = get_calibre_plugin_details()
            calibredb_path = shutil.which("calibredb") or "not found in PATH"

            f.write("[CALIBRE & PLUGINS]\n")
            f.write(f"  Calibre installed    : {'YES' if validation_report.get('calibre_installed') else 'NO'}\n")
            f.write(f"  calibredb path       : {calibredb_path}\n")

            dedrm_ok = validation_report.get('dedrm_plugin_installed')
            dedrm_ver = plugin_details.get('dedrm_version', 'Unknown')
            f.write(f"  DeDRM Plugin         : {'YES' if dedrm_ok else 'NO'}  ({dedrm_ver})\n")

            kfx_ok = validation_report.get('kfx_plugin_installed')
            kfx_ver = plugin_details.get('kfx_version', 'Unknown')
            f.write(f"  KFX Input Plugin     : {'YES' if kfx_ok else 'NO'}  (v{kfx_ver})\n")

            meta_ok = plugin_details.get('kfx_metadata_reader', False)
            if meta_ok:
                f.write("  KFX Metadata Reader  : PRESENT\n")
            else:
                f.write("  KFX Metadata Reader  : MISSING  <-- book titles will not be saved correctly\n")

            f.write(f"  Calibre import       : {'enabled' if validation_report.get('calibre_import_enabled') else 'disabled'}\n")
            auto_disabled = validation_report.get('auto_disabled_import', [])
            if auto_disabled:
                f.write("  Auto-disabled        : YES - reasons:\n")
                for reason in auto_disabled:
                    f.write(f"    - {reason}\n")
            else:
                f.write("  Auto-disabled        : NO\n")
            f.write("\n")

            # ----------------------------------------------------------------
            # Method selected
            # ----------------------------------------------------------------
            f.write("[EXTRACTION METHOD]\n")
            use_alt = validation_report.get('use_alt_method', False)
            f.write(f"  Method selected   : {'ALT (KFXArchiver)' if use_alt else 'STANDARD (KFXKeyExtractor)'}\n")
            if use_alt:
                if auto_alt:
                    f.write("  Reason            : Kindle version 2.8.3 - auto-switched (not compatible with KFXKeyExtractor)\n")
                elif config and config.get('debug_force_alt_method'):
                    f.write("  Reason            : debug_force_alt_method=true in config\n")
                else:
                    f.write("  Reason            : Alt method activated (see warnings)\n")
            f.write("\n")

            # ----------------------------------------------------------------
            # Overall result
            # ----------------------------------------------------------------
            f.write("[VALIDATION RESULT]\n")
            all_ok = validation_report.get('all_critical_met', False)
            f.write(f"  All critical met  : {'YES - proceeding' if all_ok else 'NO - script blocked'}\n")

            warnings = validation_report.get('warnings', [])
            critical_warnings = [w for w in warnings if w.get('severity') == 'CRITICAL']
            other_warnings = [w for w in warnings if w.get('severity') != 'CRITICAL']

            f.write(f"  Critical warnings : {len(critical_warnings)}\n")
            f.write(f"  Other warnings    : {len(other_warnings)}\n")
            f.write("\n")

            # ----------------------------------------------------------------
            # Warnings detail
            # ----------------------------------------------------------------
            if warnings:
                f.write("[WARNINGS DETAIL]\n")
                for i, w in enumerate(warnings, 1):
                    f.write(f"  [{i}] [{w.get('severity', '?')}] {w.get('component', 'Unknown')}\n")
                    impact = w.get('impact', '')
                    # Wrap long impact lines
                    if len(impact) > 65:
                        words = impact.split()
                        line = "       "
                        for word in words:
                            if len(line) + len(word) + 1 > 72:
                                f.write(line.rstrip() + "\n")
                                line = "       " + word + " "
                            else:
                                line += word + " "
                        if line.strip():
                            f.write(line.rstrip() + "\n")
                    else:
                        f.write(f"       Impact: {impact}\n")
                    install = w.get('install_url', '')
                    if install and install != 'N/A':
                        f.write(f"       URL   : {install}\n")
                f.write("\n")

            # ----------------------------------------------------------------
            # Footer
            # ----------------------------------------------------------------
            f.write("=" * 70 + "\n")
            f.write(f"Log written: {run_ts}\n")
            f.write("=" * 70 + "\n")

    except Exception as e:
        # Never crash startup because logging failed
        try:
            print_warn(f"Could not write startup log: {e}")
        except Exception:
            pass


def append_skipped_to_raw_log(raw_log_path, skipped_books):
    """
    Write a clearly-labelled block to the raw import log listing every book
    that was skipped by the pre-import Calibre library check.

    These books are filtered out BEFORE calibredb add is ever called, so they
    produce no calibredb output and would otherwise leave no trace in the raw
    log.  This helper fills that gap.

    Must be called after write_raw_log_header() and before the import loop
    starts.  Only called when enable_raw_logs=true.

    Args:
        raw_log_path:  Full path to the raw import log file.
        skipped_books: List of (asin, title, calibre_id) tuples.
                       - asin:       ASIN string (e.g. "B08XYZ1234")
                       - title:      Book title string (may be None/empty)
                       - calibre_id: Calibre book ID integer or None
    """
    if not raw_log_path or not skipped_books:
        return

    try:
        with open(raw_log_path, 'a', encoding='utf-8') as f:
            f.write("═" * 70 + "\n")
            f.write("[PRE-CHECK] BOOKS ALREADY IN CALIBRE LIBRARY — SKIPPED (not imported)\n")
            f.write("═" * 70 + "\n")
            f.write(f"Timestamp    : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
            f.write(f"Total skipped: {len(skipped_books)}\n")
            f.write("─" * 70 + "\n")
            for i, entry in enumerate(skipped_books, 1):
                # Accept both 2-tuples (asin, title) and 3-tuples (asin, title, calibre_id)
                if len(entry) >= 3:
                    asin, title, calibre_id = entry[0], entry[1], entry[2]
                else:
                    asin, title = entry[0], entry[1]
                    calibre_id = None

                title_str = title if title else "(title unknown)"
                id_str = f"Calibre ID: {calibre_id}" if calibre_id is not None else "Calibre ID: N/A"
                f.write(f"  [{i:>3}] {asin} | {id_str} | {title_str}\n")
            f.write("═" * 70 + "\n\n")
    except Exception:
        pass  # Never crash the script because logging failed


def append_to_startup_log(working_dir, section_name, lines):
    """
    Append an additional named section to the existing Logs/startup.log.

    Called after write_startup_log() for events that happen AFTER the initial
    pre-flight report is written (e.g. the dedrm.json pre-extraction check,
    which runs after config is validated).

    Args:
        working_dir:  Working directory that contains the Logs/ sub-folder.
        section_name: Short label used as the section header (e.g. "DEDRM.JSON PRE-CHECK").
        lines:        List of strings to write as the section body.  Each
                      string is written on its own line, prefixed with two spaces.
    """
    try:
        log_path = os.path.join(working_dir, "Logs", "startup.log")
        if not os.path.exists(log_path):
            return  # startup.log was never created - nothing to append to

        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        with open(log_path, 'a', encoding='utf-8') as f:
            f.write("\n")
            f.write(f"[{section_name}]  (appended {ts})\n")
            for line in lines:
                f.write(f"  {line}\n")
            f.write("\n")
    except Exception:
        pass  # Never crash the script because logging failed


# ============================================================================
# CALIBRE UTILITY FUNCTIONS
# ============================================================================

def get_library_book_count(library_path):
    """Get the total number of books in a Calibre library"""
    import sqlite3
    
    try:
        db_path = os.path.join(library_path, "metadata.db")
        conn = sqlite3.connect(db_path, timeout=5)
        cursor = conn.cursor()
        cursor.execute("SELECT COUNT(*) FROM books")
        count = cursor.fetchone()[0]
        conn.close()
        return count
    except Exception:
        return None


def verify_library_path(library_path):
    """Verify library path exists and contains metadata.db"""
    if not os.path.exists(library_path):
        return False, "Library path does not exist", None
    
    metadata_db = os.path.join(library_path, "metadata.db")
    if not os.path.exists(metadata_db):
        return False, "Not a valid Calibre library (metadata.db not found)", None
    
    book_count = get_library_book_count(library_path)
    
    return True, "", book_count


def get_last_calibre_library():
    """Read last library from Calibre's global.py.json"""
    try:
        config_file = os.path.join(
            os.path.expanduser("~"),
            "AppData", "Roaming", "calibre", "global.py.json"
        )
        
        if not os.path.exists(config_file):
            return None
        
        with open(config_file, 'r') as f:
            config = json.load(f)
        
        return config.get('library_path')
        
    except Exception:
        return None


def prompt_manual_library_path():
    """Prompt user to manually enter library path"""
    print_step("Enter Calibre Library Path")
    print("This is the folder containing metadata.db")
    print("Example: D:\\OneDrive\\Calibre-Libraries\\Temp-downloads")
    print()
    
    while True:
        library_path = input("Library path: ").strip().strip('"').strip("'")
        
        if not library_path:
            print_error("Path cannot be empty")
            continue
        
        library_path = os.path.normpath(library_path)
        
        valid, error, book_count = verify_library_path(library_path)
        
        if valid:
            print_ok(f"Library path validated: {library_path}")
            if book_count is not None:
                print(f"  Books found: {book_count}")
            else:
                print(f"  Books found: Unknown")
            print()
            return library_path
        else:
            print_error(f"Invalid library path: {error}")
            print()
            retry = input("Try again? (Y/N): ").strip().upper()
            if retry != 'Y':
                raise Exception("Library path validation cancelled")


# ============================================================================
# PHASE TRANSITION HELPER FUNCTIONS
# ============================================================================

def clear_screen_between_phases(config):
    """Clear screen if clear_screen_between_phases is enabled in config"""
    if config and config.get('clear_screen_between_phases', False):
        os.system('cls')


def display_phase_summary(phase_num, phase_name, summary_points, config, pause_seconds=5):
    """Display phase completion summary with countdown
    
    Args:
        phase_num: Phase number (1-4)
        phase_name: Name of the phase
        summary_points: List of accomplishment strings to display
        config: Configuration dict
        pause_seconds: Number of seconds for countdown (default 5)
    """
    print()
    print("=" * 70)
    print_done(f"[PHASE {phase_num}] {phase_name} - COMPLETE")
    print("=" * 70)
    print()
    print_step("Summary of accomplishments:")
    for point in summary_points:
        print(f"  ✓ {point}")
    print()
    
    # Check if pauses should be skipped
    skip_pauses = config.get('skip_phase_pauses', False) if config else False
    
    if skip_pauses:
        # Skip countdown - just display summary and continue
        print_step("Continuing to next phase...")
        print()
        return
    
    # Show countdown
    print(f"Continuing to next phase in {pause_seconds} seconds...")
    print()
    
    # Countdown
    for i in range(pause_seconds, 0, -1):
        print(f"\r  {i}...", end='', flush=True)
        time.sleep(1)
    
    print("\r" + " " * 20 + "\r", end='', flush=True)
    print()


def auto_launch_kindle(config):
    """Launch Kindle if auto_launch_kindle is enabled in config
    
    Returns True if Kindle was launched (and user closed it), False otherwise
    """
    import subprocess
    
    if not config or not config.get('auto_launch_kindle', False):
        return False
    
    print()
    print("=" * 70)
    print_step("Auto-Launch Kindle - Enabled in Configuration")
    print("=" * 70)
    print()
    
    # Check if Kindle is already running
    if is_kindle_running():
        print_warn("Kindle is already running")
        print_step("Waiting for Kindle to close before scanning books...")
        
        while is_kindle_running():
            time.sleep(1)
        
        print_ok("Kindle has closed - continuing...")
        return True
    
    # Find Kindle.exe location
    user_home = os.path.expanduser("~")
    
    appdata_kindle = os.path.join(user_home, "AppData", "Local", "Amazon", "Kindle", "application")
    appdata_exe = os.path.join(appdata_kindle, "Kindle.exe")
    
    program_files_kindle = r"C:\Program Files (x86)\Amazon\Kindle"
    program_files_exe = os.path.join(program_files_kindle, "Kindle.exe")
    
    appdata_exists = os.path.exists(appdata_exe)
    program_files_exists = os.path.exists(program_files_exe)
    
    if program_files_exists:
        kindle_dir = program_files_kindle
    elif appdata_exists:
        kindle_dir = appdata_kindle
    else:
        print_warn("Kindle.exe not found - cannot auto-launch")
        return False
    
    kindle_exe = os.path.join(kindle_dir, "Kindle.exe")
    
    if not os.path.exists(kindle_exe):
        print_warn(f"Kindle.exe not found at: {kindle_exe}")
        return False
    
    print_step("Launching Kindle.exe...")
    print(f"  Location: {kindle_exe}")
    print()
    
    # Launch Kindle
    subprocess.Popen([kindle_exe])
    
    # Wait a moment for Kindle to start
    time.sleep(2)
    
    print_ok("Kindle launched successfully")
    print_step("Waiting for Kindle to close before scanning books...")
    print("(You can close Kindle manually when ready, and the script will continue)")
    print()
    
    # Wait for Kindle to close
    while is_kindle_running():
        time.sleep(1)
    
    print_ok("Kindle has closed - continuing with key extraction...")
    return True


def prevent_kindle_auto_update():
    """
    Create/update the 'updates' file to prevent Kindle for PC from auto-updating
    Only runs if Kindle is installed
    
    This creates a simple text file in the Kindle AppData folder that prevents
    Kindle for PC from automatically checking for and installing updates.
    """
    user_home = os.path.expanduser("~")
    kindle_base = os.path.join(user_home, "AppData", "Local", "Amazon", "Kindle")
    updates_file = os.path.join(kindle_base, "updates")
    
    # Check if Kindle directory exists
    if not os.path.exists(kindle_base):
        return  # Kindle not installed, skip
    
    print_step("Configuring Kindle auto-update prevention...")
    
    try:
        # Create the updates file (no extension)
        with open(updates_file, 'w') as f:
            f.write("This file prevents Kindle for PC from auto-updating.\n")
            f.write("Created by Kindle Key Finder script.\n")
            f.write("Safe to delete if you want to allow auto-updates.\n")
        
        print_ok("Auto-update prevention file created/updated")
        print_ok(f"Location: {updates_file}")
        print("   Kindle for PC will not auto-update while this file exists")
    except Exception as e:
        print_warn(f"Could not create auto-update prevention file: {e}")
    
    print()


def display_credits_and_links():
    """
    Display final credits page with links and acknowledgments
    Can be called from multiple locations for consistency
    """
    print()
    print_banner_and_version()
    
    print("For the latest version of this script and updates, visit:")
    print_colored("https://techy-notes.com/blog/dedrm-v10-0-14-tutorial", 'cyan')
    print()
    print("Watch the video tutorial on YouTube:")
    print_colored("https://www.youtube.com/watch?v=pkii6EQEeGs", 'cyan')
    print()
    print_warn("Please subscribe to the YouTube channel!")
    print("Your support and appreciation is greatly valued.")
    print()
    print("If you'd like to show extra support this Script, consider buying me a Beer!:")
    print_colored("https://buymeacoffee.com/jadehawk", 'cyan')
    print()
    print("--------------------------------------------------")
    print_step("CREDITS")
    print("--------------------------------------------------")
    print()
    print("This script is powered by KFXKeyExtractor & KFXArchiver (by Satsuoni)")
    print("Created/Modded by: Satsuoni")
    print()
    print("Both tools are the CORE of what makes this automation possible:")
    print()
    print("  KFXKeyExtractor - Standard Method (Mode A)")
    print("    Extracts Kindle DRM keys from your Kindle for PC installation.")
    print("    Supports Kindle for PC 2.8.0 / 2.8.1 / 2.8.2")
    print()
    print("  KFXArchiver283 - Alternate Method (Mode B)")
    print("    Decrypts KFX books directly to kfx-zip format.")
    print("    Supports Kindle for PC 2.8.0 / 2.8.1 / 2.8.2 / 2.8.3")
    print("    Also used as automatic fallback when KFXKeyExtractor fails on 2.8.3.")
    print()
    print("  KFXArchiver291 - Alternate Method (Mode B)")
    print("    Decrypts KFX books directly to kfx-zip format.")
    print("    Supports Kindle for PC 2.9.1")
    print("    Automatically selected when Kindle 2.9.1 is detected.")
    print()
    print("Visit Satsuoni's GitHub profile:")
    print_colored("https://github.com/Satsuoni", 'cyan')
    print()
    print("For the DeDRM tools repository:")
    print_colored("https://github.com/Satsuoni/DeDRM_tools", 'cyan')
    print()
    print("Thank you, Satsuoni, for creating and maintaining these essential tools!")
    print()
    print("--------------------------------------------------")
    print()
    print("This script also uses the KFX Input Plugin (by jhowell)")
    print("Created by: jhowell")
    print()
    print("KFX Input Plugin enables Calibre to read Amazon KFX format ebooks,")
    print("which is essential for converting your Kindle books to EPUB or other formats.")
    print()
    print("Download the KFX Input Plugin:")
    print("https://www.mobileread.com/forums/showpost.php?p=3594876&postcount=1")
    print()
    print("Thank you, jhowell, for creating and maintaining the KFX Input Plugin!")
    print()


# ============================================================================
# FAILED BOOKS TRACKING FUNCTIONS
# ============================================================================

def load_failed_books(working_dir):
    """
    Load failed books from Failed-Books.txt
    Uses working_dir which respects fallback paths
    
    Args:
        working_dir: Working directory path
    
    Returns:
        set: Set of "ASIN - Title" entries from Failed-Books.txt
    """
    failed_books_file = os.path.join(working_dir, "Failed-Books.txt")
    failed_entries = set()
    
    try:
        if os.path.exists(failed_books_file):
            with open(failed_books_file, 'r', encoding='utf-8') as f:
                for line in f:
                    entry = line.strip()
                    if entry:
                        failed_entries.add(entry)
    except Exception as e:
        print_warn(f"Failed to load failed books file: {e}")
    
    return failed_entries


def resolve_failed_book_title(asin):
    """
    Resolve book title for failed books only.
    Always attempts to fetch title regardless of fetch_book_titles config.
    
    Args:
        asin: Amazon Standard Identification Number
    
    Returns:
        str: "ASIN - Book Title" or "ASIN - Unable to resolve book's title"
    """
    try:
        # Attempt to fetch title from Amazon using fetch-ebook-metadata
        result = subprocess.run(
            ['fetch-ebook-metadata', '-I', f'asin:{asin}'],
            capture_output=True,
            text=True,
            timeout=10,
            encoding='utf-8',
            errors='replace'
        )
        
        if result.returncode == 0 and result.stdout:
            # Parse output for title
            for line in result.stdout.split('\n'):
                if line.strip().startswith('Title'):
                    # Format: "Title               : The Sunken: A Dark Steampunk Fantasy"
                    parts = line.split(':', 1)
                    if len(parts) == 2:
                        title = parts[1].strip()
                        if title:
                            return f"{asin} - {title}"
        
        # If fetch failed or no title found, return generic message
        return f"{asin} - Unable to resolve book's title"
        
    except Exception:
        return f"{asin} - Unable to resolve book's title"


def remove_from_failed_books(working_dir, asin):
    """
    Remove a book from Failed-Books.txt by ASIN (used when alt retry succeeds).
    Rewrites the file without the matching entry.

    Args:
        working_dir: Working directory path
        asin: Amazon Standard Identification Number to remove
    """
    failed_books_file = os.path.join(working_dir, "Failed-Books.txt")

    if not os.path.exists(failed_books_file):
        return

    try:
        with open(failed_books_file, 'r', encoding='utf-8') as f:
            lines = f.readlines()

        # Keep lines that do NOT start with this ASIN
        filtered = [ln for ln in lines if not ln.strip().startswith(f"{asin} -") and ln.strip() != asin]

        if len(filtered) == len(lines):
            return  # Nothing changed, skip rewrite

        with open(failed_books_file, 'w', encoding='utf-8') as f:
            f.writelines(filtered)

    except Exception as e:
        print_warn(f"Failed to update Failed-Books.txt after alt retry: {e}")


def append_to_failed_books(working_dir, title, asin):
    """
    Append book to Failed-Books.txt (no duplicates)
    Format: "ASIN - Book Title"
    Uses working_dir which respects fallback paths
    
    ALWAYS attempts to resolve title for failed books
    
    Args:
        working_dir: Working directory path
        title: Book title (or ASIN if title not available)
        asin: Amazon Standard Identification Number
    """
    failed_books_file = os.path.join(working_dir, "Failed-Books.txt")
    
    try:
        # Ensure directory exists
        os.makedirs(os.path.dirname(failed_books_file), exist_ok=True)
        
        # Load existing entries to check for duplicates
        existing_entries = load_failed_books(working_dir)
        
        # Check if title is actually an ASIN (no title was fetched)
        # This happens when fetch_book_titles is disabled
        if title == asin:
            # Always resolve title for failed books
            entry = resolve_failed_book_title(asin)
        else:
            # Title was already fetched, reformat as "ASIN - Title"
            entry = f"{asin} - {title}"
        
        # Only append if not already in file
        if entry not in existing_entries:
            with open(failed_books_file, 'a', encoding='utf-8') as f:
                f.write(f"{entry}\n")
    except Exception as e:
        print_warn(f"Failed to update failed books file: {e}")
