#!/usr/bin/env python3
"""
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.

This script automatically discovers model folders from LoRA Manager's settings.json,
finds JSON files with the same basename as model files (e.g., `model.json` for
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.

Fields migrated:
- "activation text" → civitai.trainedWords (array of trigger words)
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
- "notes" → notes (user-defined notes)

Supported model types: LoRA, Checkpoint

Usage:
    python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]

The script will:
1. Read settings.json to find all configured model folders
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
3. Find corresponding legacy metadata JSON files
4. Migrate data to .metadata.json files
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any

from platformdirs import user_config_dir

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)

APP_NAME = "ComfyUI-LoRA-Manager"
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)


def resolve_settings_path() -> Path:
    repo_root = Path(__file__).parent.parent.resolve()
    portable = repo_root / "settings.json"
    if portable.exists():
        payload = load_json(portable)
        if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
            return portable

    return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"


def load_json(path: Path) -> dict[str, Any]:
    try:
        with path.open("r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return {}
    except json.JSONDecodeError as exc:
        logger.error(f"Invalid JSON in {path}: {exc}")
        return {}
    except OSError as exc:
        logger.error(f"Cannot read {path}: {exc}")
        return {}


def expand_path(value: str) -> str:
    return str(Path(value).expanduser().resolve(strict=False))


def normalize_path_list(value: Any) -> list[str]:
    if isinstance(value, str):
        return [expand_path(value)] if value else []
    if isinstance(value, list):
        return [expand_path(item) for item in value if isinstance(item, str) and item]
    return []


def dedupe(values: list[str]) -> list[str]:
    seen: set[str] = set()
    result: list[str] = []
    for value in values:
        if value not in seen:
            result.append(value)
            seen.add(value)
    return result


def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
    roots: dict[str, list[str]] = {}
    active_library = settings.get("active_library") or "default"
    sources = [settings]
    library = settings.get("libraries", {}).get(active_library)
    if isinstance(library, dict):
        sources.insert(0, library)
    for source in sources:
        folder_paths = source.get("folder_paths")
        if isinstance(folder_paths, dict):
            for key, value in folder_paths.items():
                roots.setdefault(key, []).extend(normalize_path_list(value))
    for default_key, folder_key in (
        ("default_lora_root", "loras"),
        ("default_checkpoint_root", "checkpoints"),
        ("default_embedding_root", "embeddings"),
        ("default_unet_root", "unet"),
    ):
        value = settings.get(default_key)
        if isinstance(value, str) and value:
            roots.setdefault(folder_key, []).append(expand_path(value))
    return {key: dedupe(values) for key, values in roots.items()}


def find_model_files(directory: Path) -> list[Path]:
    model_files = []
    for ext in MODEL_EXTENSIONS:
        model_files.extend(directory.rglob(f"*{ext}"))
    return model_files


def find_legacy_metadata(model_path: Path) -> Path | None:
    base_name = model_path.stem
    legacy_path = model_path.with_name(f"{base_name}.json")
    if legacy_path.exists() and legacy_path.is_file():
        return legacy_path
    return None


def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
    try:
        with open(legacy_path, "r", encoding="utf-8") as f:
            return json.load(f)
    except json.JSONDecodeError as e:
        logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
        return None
    except Exception as e:
        logger.error(f"Error reading legacy file {legacy_path}: {e}")
        return None


def load_metadata(metadata_path: Path) -> dict[str, Any]:
    if not metadata_path.exists():
        return {}
    try:
        with open(metadata_path, "r", encoding="utf-8") as f:
            return json.load(f)
    except json.JSONDecodeError as e:
        logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
        return {}
    except Exception as e:
        logger.error(f"Error reading metadata file {metadata_path}: {e}")
        return {}


def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
    if dry_run:
        logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
        return True
    temp_path = metadata_path.with_suffix(".tmp")
    try:
        with open(temp_path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
        os.replace(temp_path, metadata_path)
        return True
    except Exception as e:
        logger.error(f"Error saving metadata to {metadata_path}: {e}")
        if temp_path.exists():
            try:
                temp_path.unlink()
            except:
                pass
        return False


def migrate_metadata(
    legacy_data: dict[str, Any],
    existing_metadata: dict[str, Any],
    model_type: str
) -> dict[str, Any] | None:
    metadata = existing_metadata.copy()
    changes_made = False
    if "civitai" not in metadata:
        metadata["civitai"] = {}
    activation_text = legacy_data.get("activation text")
    if activation_text and isinstance(activation_text, str):
        trigger_words = [
            word.strip()
            for word in activation_text.replace("\n", ",").split(",")
            if word.strip()
        ]
        if trigger_words:
            existing_trained = metadata["civitai"].get("trainedWords", [])
            if not isinstance(existing_trained, list):
                existing_trained = []
            merged = list(dict.fromkeys(existing_trained + trigger_words))
            if merged != existing_trained:
                metadata["civitai"]["trainedWords"] = merged
                changes_made = True
                logger.debug(f"  Migrated activation text: {trigger_words}")
    if model_type == "lora":
        preferred_weight = legacy_data.get("preferred weight")
        if preferred_weight is not None:
            try:
                weight_value = float(preferred_weight)
                usage_tips_str = metadata.get("usage_tips", "{}")
                if isinstance(usage_tips_str, str):
                    try:
                        usage_tips = json.loads(usage_tips_str)
                    except json.JSONDecodeError:
                        usage_tips = {}
                elif isinstance(usage_tips_str, dict):
                    usage_tips = usage_tips_str
                else:
                    usage_tips = {}
                if "strength" not in usage_tips:
                    usage_tips["strength"] = weight_value
                    metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
                    changes_made = True
                    logger.debug(f"  Migrated preferred weight: {weight_value}")
            except (ValueError, TypeError) as e:
                logger.warning(f"  Could not parse preferred weight '{preferred_weight}': {e}")
    else:
        if legacy_data.get("preferred weight") is not None:
            logger.debug("  Skipping 'preferred weight' for non-LoRA model")
    notes = legacy_data.get("notes")
    if notes and isinstance(notes, str) and notes.strip():
        existing_notes = metadata.get("notes", "")
        if not existing_notes:
            metadata["notes"] = notes.strip()
            changes_made = True
            logger.debug("  Migrated notes")
        elif notes.strip() not in existing_notes:
            metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
            changes_made = True
            logger.debug("  Appended notes")
    return metadata if changes_made else None


def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
    legacy_path = find_legacy_metadata(model_path)
    if not legacy_path:
        return True
    logger.info(f"Processing: {model_path.name} ({model_type})")
    logger.info(f"  Found legacy metadata: {legacy_path.name}")
    legacy_data = load_legacy_metadata(legacy_path)
    if legacy_data is None:
        return False
    metadata_path = model_path.with_suffix(".metadata.json")
    existing_metadata = load_metadata(metadata_path)
    migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
    if migrated is None:
        logger.info("  No changes needed (fields already exist or no migratable data)")
        return True
    if save_metadata(metadata_path, migrated, dry_run):
        logger.info(f"  ✓ Successfully migrated metadata to: {metadata_path.name}")
        return True
    else:
        logger.error("  ✗ Failed to save metadata")
        return False


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python scripts/migrate_legacy_metadata.py
  python scripts/migrate_legacy_metadata.py --dry-run
  python scripts/migrate_legacy_metadata.py --verbose
        """
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Preview changes without modifying any files"
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Enable verbose output"
    )
    args = parser.parse_args()
    if args.verbose:
        logging.getLogger().setLevel(logging.DEBUG)
    settings_path = resolve_settings_path()
    logger.info(f"Using settings: {settings_path}")
    settings = load_json(settings_path)
    if not settings:
        logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
        return 1
    roots = get_model_roots(settings)
    if not roots:
        logger.error("No model folders configured in settings.json.")
        return 1
    lora_roots = roots.get("loras", [])
    checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
    all_roots = []
    for root_list in [lora_roots, checkpoint_roots]:
        for root in root_list:
            path = Path(root)
            if path.exists() and path.is_dir():
                all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
    if not all_roots:
        logger.error("No valid model folders found.")
        return 1
    logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
    processed = 0
    migrated = 0
    errors = 0
    skipped = 0
    lora_count = 0
    checkpoint_count = 0
    for root_path, model_type in all_roots:
        logger.info(f"Scanning: {root_path} ({model_type})")
        model_files = find_model_files(root_path)
        logger.debug(f"  Found {len(model_files)} model files")
        for model_path in model_files:
            legacy_path = find_legacy_metadata(model_path)
            if not legacy_path:
                skipped += 1
                continue
            processed += 1
            if process_model(model_path, model_type, dry_run=args.dry_run):
                migrated += 1
                if model_type == "lora":
                    lora_count += 1
                else:
                    checkpoint_count += 1
            else:
                errors += 1
    logger.info("\n" + "=" * 50)
    logger.info("Migration Summary:")
    logger.info(f"  Models with legacy metadata: {processed}")
    logger.info(f"  Successfully migrated: {migrated}")
    logger.info(f"    - LoRA models: {lora_count}")
    logger.info(f"    - Checkpoint models: {checkpoint_count}")
    logger.info(f"  Errors: {errors}")
    logger.info(f"  Skipped (no legacy file): {skipped}")
    if args.dry_run:
        logger.info("\n  [DRY RUN MODE - No files were modified]")
    return 0 if errors == 0 else 1


if __name__ == "__main__":
    sys.exit(main())
