#!/usr/bin/env python3
"""
Cryptoments v1 → v2 마이그레이션 스크립트 (v2.2)

v1 DB (coin_payments) → v2 DB (cryptoments_db)

전제 조건:
  - v2 DB에 DDL v2.0이 적용된 상태 (40개 테이블)
  - v2 blockchain_networks, currencies 초기 데이터 존재

규칙:
  - ETH 데이터 전체 스킵
  - SYSTEM/FEE 지갑 스킵, HOT+MASTER 만 마이그레이션
  - 성공 상태(CONFIRMED/SETTLED/COMPLETED)만 마이그레이션 (트랜잭션)
  - 파트너 ID 보존
  - wallet_addresses 는 새 ID 발급, FK 정합성 유지

배제 항목 (사용자 결정):
  - FEE / SYSTEM 지갑 (v2에서 GAS/RELAYER로 별도 생성)
  - partner_telegram_chats / partner_telegram_configs (신규 봇으로 이동)
  - collection_queue (스킵)
  - currency_prices (스킵)
  - payment_links (스킵)
  - nonce_tracker (v2에서 온체인 동기화 필요)
  - external_wallets → 이관 (v1 체인별 3행 → v2 JSON 1행)
  - axim_payments (v2에서 재연동)
  - wallet_approvals (v2 컨트랙트 불일치)
  - deposit_reservations (v1 예약 만료/완료)
  - gas_cost_records (v2에서 새로 집계)

사용법:
  python3 migrate_v1_to_v2.py                # 실제 마이그레이션
  python3 migrate_v1_to_v2.py --dry-run      # 건수만 확인 (INSERT 없음)
"""

import mysql.connector
import json
import uuid
import sys
import traceback
import hashlib
import os
from datetime import datetime
from decimal import Decimal
from base64 import b64decode, b64encode
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

# ─── 설정 ────────────────────────────────────────────────────────────────────

V1_DB = {
    'host': 'localhost', 'port': 3306,
    'user': 'root', 'password': '1qw2!QW@',
    'database': 'coin_payments',
    'charset': 'utf8mb4',
}

V2_DB = {
    'host': 'localhost', 'port': 3306,
    'user': 'root', 'password': '1qw2!QW@',
    'database': 'cryptoments_db',
    'charset': 'utf8mb4',
}

DRY_RUN = '--dry-run' in sys.argv

# ─── 암호화 키 설정 ──────────────────────────────────────────────────────────
# v1: Java AES/GCM — SHA-256(masterKey.getBytes()) → AES key, Base64(iv+ciphertext+authTag)
# v2: Node.js AES-256-GCM — hex master key → AES key, hex(iv+ciphertext+authTag)
ENCRYPTION_MASTER_KEY = 'a8e1e3745f4323c21dec472b2c6867d3e6914c62247659a249b25e17ee729864'

IV_LENGTH = 12
AUTH_TAG_LENGTH = 16


def _get_v1_key() -> bytes:
    """v1 Java 방식: SHA-256(masterKey.getBytes('UTF-8')) → 32 bytes AES key"""
    return hashlib.sha256(ENCRYPTION_MASTER_KEY.encode('utf-8')).digest()


def _get_v2_key() -> bytes:
    """v2 Node.js 방식: hex master key → 32 bytes AES key"""
    return bytes.fromhex(ENCRYPTION_MASTER_KEY)


def decrypt_v1(encrypted_base64: str) -> str:
    """v1 형식 복호화: Base64 → iv(12) + ciphertext + authTag(16)"""
    key = _get_v1_key()
    data = b64decode(encrypted_base64)
    iv = data[:IV_LENGTH]
    ciphertext_and_tag = data[IV_LENGTH:]  # AESGCM expects ciphertext+tag concatenated
    aesgcm = AESGCM(key)
    plaintext = aesgcm.decrypt(iv, ciphertext_and_tag, None)
    return plaintext.decode('utf-8')


def encrypt_v2(plaintext: str) -> str:
    """v2 형식 암호화: hex(iv(12) + ciphertext + authTag(16))"""
    key = _get_v2_key()
    iv = os.urandom(IV_LENGTH)
    aesgcm = AESGCM(key)
    ciphertext_and_tag = aesgcm.encrypt(iv, plaintext.encode('utf-8'), None)
    return (iv + ciphertext_and_tag).hex()


def re_encrypt_v1_to_v2(encrypted_base64: str) -> str:
    """v1 Base64 → 복호화 → v2 hex 재암호화"""
    plaintext = decrypt_v1(encrypted_base64)
    return encrypt_v2(plaintext)


# ─── 매핑 테이블 ─────────────────────────────────────────────────────────────

# v1 chain_type → v2 network_id (런타임 조회로 대체되지만 폴백용)
CHAIN_TO_NETWORK = {}

# v1 (currency_type, chain_type) → v2 currency_id (런타임 조회)
CURRENCY_MAP = {}

# v1 hd_wallet_id → v2 hd_wallet_id (런타임 매핑)
HD_WALLET_MAP = {}

# v1 wallet_address.id → v2 wallet_addresses.id (런타임 매핑)
WALLET_ADDR_MAP = {}

# v1 transaction.id → v2 deposit.id / withdrawal.id (런타임 매핑)
DEPOSIT_MAP = {}
WITHDRAWAL_MAP = {}

# ─── 인프라 지갑 시드 데이터 ──────────────────────────────────────────────────
# create-infra API로 생성된 ADMIN/GAS/RELAYER 지갑 (온체인 배포 완료)
# 마이그레이션 시 이 지갑들을 v2 DB에 import 해야 함 (새로 생성하면 안 됨)
#
# encrypted_private_key: AES-256-GCM hex 형식 (v2 네이티브)
# 출처: cryptoments_v2_2026-03-27.sql 백업

# v2 HD Wallets (v2에서 신규 생성, v1과 별개)
# 동일 master_seed를 네트워크별 derivation_base_path로 분기
# master_seed_encrypted: AES-256-GCM hex (v2 네이티브)
# ⚠️ network_id는 하드코딩하지 않음 — chain 키로 런타임 조회 (CHAIN_TO_NETWORK)
INFRA_HD_WALLETS = [
    {'chain': 'BSC', 'derivation_base_path': "m/44'/60'/1'",
     'master_seed_encrypted': 'f8b3508e7042e648aac86c5864376a225aaf047231446d6c3a77db10b017cb4da95257a5852454285939364fc26446930d53a324f29b8a3ace62f49cebe6261fd8c1bea3eaf6ee08274af7f1051907d1d882e50934cacc66215643793231474b7da7924a60756c2c8bb394984acbd925c0d0f1abcf3be8f632f415937ffc45d450e331f723bd3797470d3f774dca5fa0c6263631bbff53cecc01adc2'},
    {'chain': 'POLYGON', 'derivation_base_path': "m/44'/60'/3'",
     'master_seed_encrypted': 'f8b3508e7042e648aac86c5864376a225aaf047231446d6c3a77db10b017cb4da95257a5852454285939364fc26446930d53a324f29b8a3ace62f49cebe6261fd8c1bea3eaf6ee08274af7f1051907d1d882e50934cacc66215643793231474b7da7924a60756c2c8bb394984acbd925c0d0f1abcf3be8f632f415937ffc45d450e331f723bd3797470d3f774dca5fa0c6263631bbff53cecc01adc2'},
    {'chain': 'TRON', 'derivation_base_path': "m/44'/195'/2'",
     'master_seed_encrypted': 'f8b3508e7042e648aac86c5864376a225aaf047231446d6c3a77db10b017cb4da95257a5852454285939364fc26446930d53a324f29b8a3ace62f49cebe6261fd8c1bea3eaf6ee08274af7f1051907d1d882e50934cacc66215643793231474b7da7924a60756c2c8bb394984acbd925c0d0f1abcf3be8f632f415937ffc45d450e331f723bd3797470d3f774dca5fa0c6263631bbff53cecc01adc2'},
]

INFRA_WALLETS = [
    # ── ADMIN (3개 네트워크) ── hd_wallet_id는 Phase 0-A에서 런타임 설정
    # ⚠️ network_id는 하드코딩하지 않음 — chain 키로 런타임 조회 (CHAIN_TO_NETWORK)
    {'address': '0xF9cbB86D5fae82183AA8e0E4fFf05a4C917414FE', 'chain': 'BSC',
     'derivation_index': 0, 'derivation_path': "m/44'/60'/3'/0/0", 'wallet_type': 'ADMIN',
     'encrypted_private_key': 'ad07ad7b27fdac7117fa9ab5ee794a10053cfff6b0d60ab52ad06c2f6c3d2550fab4d30c016d08819c5a768acd6567745778f5962bcc80eb9eb248f23deb36950ec807edd946b371e3903cfd5e40c7dcff9501ad61d8b8b1163254481838'},
    {'address': 'TXSXNMTsaYiiXvkXeiyBzu7rAxFSrDho1W', 'chain': 'TRON',
     'derivation_index': 0, 'derivation_path': "m/44'/195'/2'/0/0", 'wallet_type': 'ADMIN',
     'encrypted_private_key': '1b0c41da8e44af2b481182cbc859d08a05e8751457a73ecb6b242a3eec02feb4c6717022d05b046b61fb22a24885c2b8853e748f845e24a8616fc58bba19ce4cdd3147936830fa5d4da56e15fa4ddf0d94ef4cbf5747912c8cd727441f04'},
    {'address': '0x00113718ecF08339019e0E0F12Ced3a2dd9209E1', 'chain': 'POLYGON',
     'derivation_index': 0, 'derivation_path': "m/44'/60'/1'/0/0", 'wallet_type': 'ADMIN',
     'encrypted_private_key': '9f55ed5e8ccf69957bec1beb2499e5e83445cfa2ebd6e06b820f2f7286d198d7d5fb6c62456b76aff688d6fd63184a53d780aa694dad5c92912031e6689a767003748ae1375debedf4cde6b7e48517ef777052640bf702d2fbbaf856d577'},
    # ── GAS (3개 네트워크) ──
    {'address': '0x85a1c69F246dB88c74a37b32966360b5c363DA0c', 'chain': 'BSC',
     'derivation_index': 1, 'derivation_path': "m/44'/60'/3'/0/1", 'wallet_type': 'GAS',
     'encrypted_private_key': '2f25bef0edf58ad89e53e646c54a1f26e78d137c623b5c98141851ab4f1c65511a9a103f1a7e7d004ca3e09701b93910a9d1528ee11f1eccbd9120734d7922b254ead8976c0328aa2944a3501a9f9afac9d64421264f371e493e1bf5c665'},
    {'address': 'TBqjBPuDHRRE5EA2sGAVNNF1HJVPAP55DL', 'chain': 'TRON',
     'derivation_index': 1, 'derivation_path': "m/44'/195'/2'/0/1", 'wallet_type': 'GAS',
     'encrypted_private_key': 'c66e9190250f95c18ff5196bf52d779a6dc905f26e34117ac35fbfb0e80e5dcfaaf737fad12080069ffa823d9c40e199678fb75cb9658cfcc1eb264b8ac1d61f30f90fd427888d6fd37077fa1abfc5dd171320bfcd347ab8f95a66f62593'},
    {'address': '0x5b8Bd80C1cF2356a48626Fb895e19A8F88703F87', 'chain': 'POLYGON',
     'derivation_index': 1, 'derivation_path': "m/44'/60'/1'/0/1", 'wallet_type': 'GAS',
     'encrypted_private_key': '3a9fe2b99b600a7acad8b6f72f145d3da1f66c4bd9f4b5b7031ad0d5366dbb74682f764aac1973b949e9ba5265bd6f06bdf9bb41c6be2231e0dd808771cabe3d756106b34d0975be928c661a1f1199fc8199fc28a1f2e30316d3d380a496'},
    # ── RELAYER (3개 네트워크) ──
    {'address': '0x6e8719d2866Ea41f5510AC62e0004d0149060cd0', 'chain': 'BSC',
     'derivation_index': 3, 'derivation_path': "m/44'/60'/3'/0/3", 'wallet_type': 'RELAYER',
     'encrypted_private_key': 'e03d4ecf8d248722e74ddafa2b0304d551b8ad1fbf4e633da572781b9735277e830023c9ea1ab64a1314c473af8312c66f9a088cf38bb999007cf789b3172b7b27b0380d15df7bf57647c4611f526c8fe83c3a248a3bc491ddd99b538b54'},
    {'address': '0xE36131e3Bfc9B924c0ccFef16e8F77445ff9E1cE', 'chain': 'POLYGON',
     'derivation_index': 2, 'derivation_path': "m/44'/60'/1'/0/2", 'wallet_type': 'RELAYER',
     'encrypted_private_key': 'ef816c4874692c4326db4e3ecfa6bfeeea780c77603f000d57b4fdb695430323ddf8653a70959c104b0ba1d4b7d17b35e38277656197bd441fa3a08ac919d5b9377e82bef301e4708f80c84bd299e9fa101592665ce653caa534078f4559'},
    {'address': 'TW3x6Mz3B7vNUua9MQAacNJFJ2hYNcPwiG', 'chain': 'TRON',
     'derivation_index': 2, 'derivation_path': "m/44'/195'/2'/0/2", 'wallet_type': 'RELAYER',
     'encrypted_private_key': 'f39f4a8dc2c27dab789f154923468b8a71c2abd3f57773e41f3ce006a8559910535b675a0e29d73e9d594f81be581e9e55033171ec4f051db1622ab7379ff13c729702bcc580efdd639ae0f7a36d9fef3765d679be78cd9214cb983b364c'},
]

# 온체인 배포된 Relayer 컨트랙트 (ADMIN이 owner)
# ⚠️ network_id는 하드코딩하지 않음 — chain 키로 런타임 조회 (CHAIN_TO_NETWORK)
INFRA_RELAYER_CONTRACTS = [
    {'chain': 'BSC', 'contract_address': '0x841c5833f70438F43671e5b66e87D9d6CED406Fc',
     'owner_wallet_type': 'ADMIN', 'owner_chain': 'BSC',
     'deploy_tx_hash': '0x3a2f76f24f170baa862df1cd26abd63c257eec42db9d5a60dac4252fd04d018f',
     'abi_version': '1.0', 'status': 'ACTIVE'},
    {'chain': 'POLYGON', 'contract_address': '0x841c5833f70438F43671e5b66e87D9d6CED406Fc',
     'owner_wallet_type': 'ADMIN', 'owner_chain': 'POLYGON',
     'deploy_tx_hash': '0xb30c194346545622b272b425fe12969bf99d9ea1a0756e983c15f923f77eb248',
     'abi_version': '1.0', 'status': 'ACTIVE'},
    {'chain': 'TRON', 'contract_address': 'TRce4E4oTuErHwahWTdSebGGGgR2EZ1BcX',
     'owner_wallet_type': 'ADMIN', 'owner_chain': 'TRON',
     'deploy_tx_hash': '25350435e349dd660068c9506c021c678ebd375a4f7f61454b660003032585dd',
     'abi_version': '1.0', 'status': 'ACTIVE'},
]

# ─── 유틸리티 ─────────────────────────────────────────────────────────────────

def generate_code(prefix):
    """고유 코드 생성: DEP-xxxxxxxx, WDR-xxxxxxxx 등"""
    return f"{prefix}-{uuid.uuid4().hex[:12].upper()}"


def log(msg):
    print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")


def log_phase(num, title):
    log(f"\n━━━ Phase {num}. {title} ━━━")


# ─── 메인 ─────────────────────────────────────────────────────────────────────

def main():
    if DRY_RUN:
        log("🔍 DRY RUN 모드 — 실제 INSERT 없이 건수만 확인합니다.")

    v1 = mysql.connector.connect(**V1_DB)
    v2 = mysql.connector.connect(**V2_DB)
    c1 = v1.cursor(dictionary=True)
    c2 = v2.cursor(dictionary=True)

    results = {}  # phase별 건수 추적

    try:
        # ══════════════════════════════════════════════════════════════════
        # Phase CLEAR: v2 테이블 초기화 (마이그레이션 대상 + 배제 항목)
        # ══════════════════════════════════════════════════════════════════
        log_phase("CLEAR", "v2 테이블 초기화")

        # ── 마이그레이션 대상 테이블 (INSERT 되는 것들) ──
        migration_tables = [
            'webhook_delivery_logs',        # FK: partner_id
            'deposits',                     # FK: partner_id, wallet_address_id, currency_id
            'withdrawals',                  # FK: partner_id, currency_id
            'wallet_index_manager',         # FK: hd_wallet_id
            'wallet_balances',              # FK: wallet_address_id, currency_id
            # wallet_keys — 인프라 지갑 키 보존 위해 별도 처리 (wallet_addresses와 동일)
            'partner_withdrawal_policies',  # FK: partner_id
            'partner_exchange_rate_policies',# FK: partner_id
            'ledger_entries',               # FK: partner_id, currency_id, network_id
            'partner_axim_settings',        # FK: partner_id
            'partner_chain_configs',        # FK: partner_id, network_id, currency_id
            'external_wallets',             # FK: partner_id
            'admins',
        ]

        # ── 배제 항목 (이관 안 하지만 깨끗하게 비워야 하는 것들) ──
        excluded_tables = [
            'nonce_tracker',                # 온체인 자동 초기화
            'axim_payments',                # v2에서 새로 시작
            'wallet_approvals',             # v2 컨트랙트 불일치
            'deposit_reservations',         # v1 예약 만료/완료
            'gas_cost_records',             # v2에서 새로 집계
            'collection_queue',             # 실시간 처리
            'currency_prices',              # 실시간 시세
            'payment_links',                # 스킵
            'partner_telegram_configs',     # 신규 봇으로 이동
            'partner_telegram_chats',       # 신규 봇으로 이동
            'notification_events',          # v2 신규
            'notification_deliveries',      # v2 신규
            'webhook_events',               # v2 신규
            'monitor_address_registrations',# v2 신규
            # ledger_entries — Phase 19b에서 마이그레이션 (excluded에서 제거)
            'system_settings',              # v2 신규
        ]

        if DRY_RUN:
            log(f"  ⏭️  DRY RUN — CLEAR 스킵")
            log(f"     마이그레이션 대상: {len(migration_tables)}개 테이블 TRUNCATE 예정")
            log(f"     배제 항목: {len(excluded_tables)}개 테이블 TRUNCATE 예정")
            log(f"     wallet_addresses: HOT/MASTER DELETE 예정 (GAS/RELAYER 보존)")
            log(f"     partners: TRUNCATE 예정")
        else:
            c2.execute("SET FOREIGN_KEY_CHECKS = 0")

            clear_count = 0
            for tbl in migration_tables + excluded_tables:
                try:
                    c2.execute(f"TRUNCATE TABLE {tbl}")
                    clear_count += 1
                except Exception as e:
                    log(f"  ⚠️  {tbl} TRUNCATE 실패 (테이블 없음?): {e}")

            # 인프라 관련 테이블 전체 TRUNCATE
            # (Phase 0-A에서 hd_wallets + 인프라 지갑 + 컨트랙트 새로 import)
            c2.execute("TRUNCATE TABLE wallet_keys")
            c2.execute("TRUNCATE TABLE wallet_addresses")
            c2.execute("TRUNCATE TABLE relayer_contracts")
            c2.execute("TRUNCATE TABLE hd_wallets")
            log(f"  hd_wallets + wallet_addresses + wallet_keys + relayer_contracts: TRUNCATE")

            # partners: FK 의존 테이블을 위에서 이미 비웠으므로 안전하게 TRUNCATE
            c2.execute("TRUNCATE TABLE partners")
            clear_count += 1

            c2.execute("SET FOREIGN_KEY_CHECKS = 1")
            v2.commit()

            log(f"  ✅ {clear_count + 1}개 테이블 TRUNCATE + wallet_addresses HOT/MASTER DELETE 완료")

        # ══════════════════════════════════════════════════════════════════
        # Phase 0-A: 인프라 지갑 Import (ADMIN/GAS/RELAYER + 컨트랙트)
        # ══════════════════════════════════════════════════════════════════
        log_phase('0-A', "인프라 지갑 Import")

        # ── chain → network_id 매핑 먼저 조회 (시드 데이터에 network_id 하드코딩 제거) ──
        c2.execute("SELECT id, chain_symbol FROM blockchain_networks")
        chain_to_net = {r['chain_symbol']: r['id'] for r in c2.fetchall()}
        log(f"  chain→network_id 매핑: {chain_to_net}")

        infra_addr_map = {}  # (wallet_type, network_id) → v2 wallet_address_id

        if DRY_RUN:
            log(f"  ⏭️  DRY RUN — hd_wallets {len(INFRA_HD_WALLETS)}개 INSERT 예상")
            log(f"  ⏭️  DRY RUN — 인프라 지갑 {len(INFRA_WALLETS)}개 INSERT 예상")
            log(f"  ⏭️  DRY RUN — 컨트랙트 {len(INFRA_RELAYER_CONTRACTS)}개 INSERT 예상")
        else:
            # hd_wallets INSERT (v2 신규 시드)
            hd_wallet_map = {}  # network_id → v2 hd_wallet_id
            for hw in INFRA_HD_WALLETS:
                net_id = chain_to_net[hw['chain']]
                hw['network_id'] = net_id
                c2.execute("""
                    INSERT INTO hd_wallets (
                        network_id, master_seed_encrypted, encryption_algorithm,
                        derivation_base_path, current_index, max_index,
                        status, created_at, updated_at
                    ) VALUES (
                        %(network_id)s, %(master_seed_encrypted)s, 'AES-256-GCM',
                        %(derivation_base_path)s, 0, 10000000,
                        'ACTIVE', NOW(), NOW()
                    )
                """, hw)
                hd_wallet_map[net_id] = c2.lastrowid

            log(f"  ✅ hd_wallets: {len(INFRA_HD_WALLETS)}개 INSERT {hd_wallet_map}")

            # wallet_addresses INSERT (chain → network_id 런타임 변환)
            for w in INFRA_WALLETS:
                net_id = chain_to_net[w['chain']]
                w['network_id'] = net_id
                w['hd_wallet_id'] = hd_wallet_map[net_id]
                c2.execute("""
                    INSERT INTO wallet_addresses (
                        address, network_id, hd_wallet_id, derivation_index,
                        derivation_path, wallet_type, partner_id, partner_user_id,
                        status, monitor_registered, created_at, updated_at
                    ) VALUES (
                        %(address)s, %(network_id)s, %(hd_wallet_id)s, %(derivation_index)s,
                        %(derivation_path)s, %(wallet_type)s, NULL, NULL,
                        'ACTIVE', 0, NOW(), NOW()
                    )
                """, w)
                addr_id = c2.lastrowid
                infra_addr_map[(w['wallet_type'], net_id)] = addr_id

                # wallet_keys INSERT (v2 AES-256-GCM hex)
                c2.execute("""
                    INSERT INTO wallet_keys (
                        wallet_address_id, encrypted_private_key, encryption_algorithm,
                        key_version, is_active, created_at, updated_at
                    ) VALUES (
                        %(wallet_address_id)s, %(encrypted_private_key)s, 'AES-256-GCM',
                        'v2', 1, NOW(), NOW()
                    )
                """, {
                    'wallet_address_id': addr_id,
                    'encrypted_private_key': w['encrypted_private_key'],
                })

            log(f"  ✅ 인프라 지갑: {len(INFRA_WALLETS)}개 INSERT (wallet_addresses + wallet_keys)")

            # relayer_contracts INSERT (chain → network_id 런타임 변환)
            for rc in INFRA_RELAYER_CONTRACTS:
                net_id = chain_to_net[rc['chain']]
                owner_net_id = chain_to_net[rc['owner_chain']]
                owner_id = infra_addr_map.get((rc['owner_wallet_type'], owner_net_id))
                rc['network_id'] = net_id
                c2.execute("""
                    INSERT INTO relayer_contracts (
                        network_id, contract_address, owner_address_id, deploy_tx_hash,
                        abi_version, status, created_at, updated_at
                    ) VALUES (
                        %(network_id)s, %(contract_address)s, %(owner_address_id)s,
                        %(deploy_tx_hash)s, %(abi_version)s, %(status)s, NOW(), NOW()
                    )
                """, {**rc, 'owner_address_id': owner_id})

            log(f"  ✅ Relayer 컨트랙트: {len(INFRA_RELAYER_CONTRACTS)}개 INSERT")

            v2.commit()
            log(f"  ✅ 인프라 지갑 Import 완료")

        # ══════════════════════════════════════════════════════════════════
        # Phase 0-B: 런타임 매핑 테이블 초기화
        # ══════════════════════════════════════════════════════════════════
        log_phase('0-B', "런타임 매핑 초기화")

        # v2 blockchain_networks → CHAIN_TO_NETWORK
        c2.execute("SELECT id, chain_symbol FROM blockchain_networks")
        for r in c2.fetchall():
            CHAIN_TO_NETWORK[r['chain_symbol']] = r['id']
        log(f"  networks: {CHAIN_TO_NETWORK}")

        # v2 currencies → CURRENCY_MAP
        c2.execute("SELECT id, symbol, network_id FROM currencies")
        v2_currencies = c2.fetchall()
        # network_id → chain_symbol 역매핑
        network_to_chain = {v: k for k, v in CHAIN_TO_NETWORK.items()}
        for c in v2_currencies:
            chain = network_to_chain.get(c['network_id'])
            if chain:
                CURRENCY_MAP[(c['symbol'], chain)] = c['id']
        log(f"  currencies: {len(CURRENCY_MAP)}개 매핑")

        # v2 hd_wallets
        c2.execute("SELECT id, network_id FROM hd_wallets")
        v2_hd = {r['network_id']: r['id'] for r in c2.fetchall()}
        log(f"  v2 hd_wallets: {v2_hd}")

        # v1 hd_wallets → v2 hd_wallets 매핑 (chain_type 기반)
        c1.execute("SELECT id, chain_type FROM hd_wallets")
        for hw in c1.fetchall():
            net_id = CHAIN_TO_NETWORK.get(hw['chain_type'])
            if net_id and net_id in v2_hd:
                HD_WALLET_MAP[hw['id']] = v2_hd[net_id]
        log(f"  HD wallet 매핑 (v1→v2): {HD_WALLET_MAP}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 1: 기반 마스터 — blockchain_networks UPDATE
        # ══════════════════════════════════════════════════════════════════
        log_phase(1, "Blockchain Networks UPDATE")

        c1.execute("SELECT * FROM blockchain_networks WHERE chain_type IN ('BSC','POLYGON','TRON')")
        v1_networks = c1.fetchall()

        net_count = 0
        for n in v1_networks:
            net_id = CHAIN_TO_NETWORK.get(n['chain_type'])
            if not net_id:
                continue
            if DRY_RUN:
                net_count += 1
                continue

            c2.execute("""
                UPDATE blockchain_networks SET
                    rpc_url = %(rpc_url)s,
                    explorer_url = %(explorer_url)s,
                    explorer_tx_format = %(explorer_tx_format)s,
                    explorer_address_format = %(explorer_address_format)s,
                    block_confirmation_count = %(block_confirmation_count)s,
                    is_active = %(is_active)s
                WHERE id = %(id)s
            """, {
                'id': net_id,
                'rpc_url': n.get('rpc_url') or '',
                'explorer_url': n.get('explorer_url'),
                'explorer_tx_format': n.get('explorer_tx_url_format'),
                'explorer_address_format': n.get('explorer_address_url_format'),
                'block_confirmation_count': n.get('required_confirmations', 12),
                'is_active': n.get('is_active', 1),
            })
            net_count += 1

        results['blockchain_networks'] = net_count
        log(f"  ✅ blockchain_networks: {net_count}건 UPDATE {'(예상)' if DRY_RUN else ''}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 2: currencies UPDATE
        # ══════════════════════════════════════════════════════════════════
        log_phase(2, "Currencies UPDATE")

        c1.execute("SELECT * FROM token_supports WHERE chain_type IN ('BSC','POLYGON','TRON')")
        v1_tokens = c1.fetchall()

        cur_count = 0
        for ts in v1_tokens:
            currency_id = CURRENCY_MAP.get((ts['currency_type'], ts['chain_type']))
            if not currency_id:
                continue
            if DRY_RUN:
                cur_count += 1
                continue

            c2.execute("""
                UPDATE currencies SET
                    decimals = %(decimals)s,
                    contract_address = %(contract_address)s,
                    is_active = %(is_active)s
                WHERE id = %(id)s
            """, {
                'id': currency_id,
                'decimals': ts['decimals'],
                'contract_address': ts.get('contract_address'),
                'is_active': 1 if ts['status'] == 'ACTIVE' else 0,
            })
            cur_count += 1

        results['currencies'] = cur_count
        log(f"  ✅ currencies: {cur_count}건 UPDATE {'(예상)' if DRY_RUN else ''}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 3: Admins
        # ══════════════════════════════════════════════════════════════════
        log_phase(3, "Admins")

        c1.execute("SELECT * FROM admin ORDER BY created_at")
        v1_admins = c1.fetchall()
        log(f"  v1 admin: {len(v1_admins)}개")

        admin_count = 0
        for a in v1_admins:
            if DRY_RUN:
                admin_count += 1
                continue

            c2.execute("""
                INSERT INTO admins (
                    email, password_hash, name, role, status,
                    created_at, updated_at
                ) VALUES (
                    %(email)s, %(password_hash)s, %(name)s, %(role)s, %(status)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'email': f"{a['admin_id']}@cryptoments.io",
                'password_hash': a['password'],
                'name': a['name'],
                'role': 'SUPER_ADMIN',
                'status': a.get('status', 'ACTIVE'),
                'created_at': a['created_at'],
                'updated_at': a.get('updated_at') or a['created_at'],
            })
            admin_count += 1

        results['admins'] = admin_count
        log(f"  ✅ admins: {admin_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 4: Partners
        # ══════════════════════════════════════════════════════════════════
        log_phase(4, "Partners")

        c1.execute("""
            SELECT p.*,
                   ps.currency_exchange_rate_type, ps.fixed_exchange_rate,
                   ps.withdrawal_policy, ps.withdrawal_manual_threshold,
                   ps.deposit_callback_url, ps.withdrawal_callback_url
            FROM partners p
            LEFT JOIN partner_settings ps ON p.id = ps.partner_id
            ORDER BY p.id
        """)
        partners = c1.fetchall()
        log(f"  v1 파트너: {len(partners)}개")

        partner_count = 0
        for p in partners:
            if DRY_RUN:
                partner_count += 1
                continue

            # v2 코드 체계: P + 6자리 LPAD(id) — PartnerManagementService.generatePartnerCode() 규칙
            partner_code = f"P{p['id']:06d}"

            c2.execute("""
                INSERT INTO partners (
                    id, partner_code, name, parent_partner_id, partner_type,
                    parent_fee_rate, min_fee_rate, max_fee_cap, deposit_fee_rate,
                    api_key, api_secret_hash,
                    status, login_email, password_hash,
                    webhook_url, deposit_auto_confirm,
                    created_at, updated_at
                ) VALUES (
                    %(id)s, %(partner_code)s, %(name)s, %(parent_partner_id)s, %(partner_type)s,
                    %(parent_fee_rate)s, %(min_fee_rate)s, %(max_fee_cap)s, %(deposit_fee_rate)s,
                    %(api_key)s, %(api_secret_hash)s,
                    %(status)s, %(login_email)s, %(password_hash)s,
                    %(webhook_url)s, %(deposit_auto_confirm)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'id': p['id'],
                'partner_code': partner_code,
                'name': p['partner_name'],
                'parent_partner_id': p['parent_partner_id'],
                'partner_type': 'DISTRIBUTOR' if p['parent_partner_id'] is None else 'MERCHANT',
                'parent_fee_rate': Decimal('0'),
                'min_fee_rate': Decimal('0'),
                'max_fee_cap': p['commission_rate'] or Decimal('0'),
                'deposit_fee_rate': p['commission_rate'] or Decimal('0'),
                'api_key': p['api_key'],
                'api_secret_hash': p['api_secret'] or '',
                'status': p['status'],
                'login_email': p['email'],
                'password_hash': p['password_hash'] or '',
                'webhook_url': p.get('deposit_callback_url'),
                'deposit_auto_confirm': 0,
                'created_at': p['created_at'],
                'updated_at': p['updated_at'],
            })
            partner_count += 1

        results['partners'] = partner_count
        log(f"  ✅ partners: {partner_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 5: Partner Chain Configs
        # ══════════════════════════════════════════════════════════════════
        log_phase(5, "Partner Chain Configs")

        c1.execute("""
            SELECT * FROM partner_chain_activations
            WHERE chain_type IN ('BSC', 'POLYGON', 'TRON')
        """)
        chain_acts = c1.fetchall()
        log(f"  v1 partner_chain_activations (ETH 제외): {len(chain_acts)}개")

        # v2 USDT currencies (네트워크별)
        c2.execute("SELECT id, network_id FROM currencies WHERE symbol='USDT'")
        usdt_currencies = {r['network_id']: r['id'] for r in c2.fetchall()}

        chain_config_count = 0
        for ca in chain_acts:
            network_id = CHAIN_TO_NETWORK.get(ca['chain_type'])
            if not network_id:
                continue
            currency_id = usdt_currencies.get(network_id)
            if not currency_id:
                continue

            if DRY_RUN:
                chain_config_count += 1
                continue

            c2.execute("""
                INSERT INTO partner_chain_configs (
                    partner_id, network_id, currency_id,
                    created_at, updated_at
                ) VALUES (
                    %(partner_id)s, %(network_id)s, %(currency_id)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'partner_id': ca['partner_id'],
                'network_id': network_id,
                'currency_id': currency_id,
                'created_at': ca['created_at'],
                'updated_at': ca['updated_at'],
            })
            chain_config_count += 1

        results['partner_chain_configs'] = chain_config_count
        log(f"  ✅ partner_chain_configs: {chain_config_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 6: Partner Axim Settings
        # ══════════════════════════════════════════════════════════════════
        log_phase(6, "Partner Axim Settings")

        c1.execute("SELECT * FROM partner_axim_settings")
        axim_settings = c1.fetchall()

        axim_settings_count = 0
        for a in axim_settings:
            if DRY_RUN:
                axim_settings_count += 1
                continue

            c2.execute("""
                INSERT INTO partner_axim_settings (
                    partner_id, is_enabled, api_key, api_secret_enc,
                    created_at, updated_at
                ) VALUES (
                    %(partner_id)s, %(is_enabled)s, %(api_key)s, %(api_secret_enc)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'partner_id': a['partner_id'],
                'is_enabled': 1 if a['status'] == 'ACTIVE' else 0,
                'api_key': a.get('api_key'),
                'api_secret_enc': a.get('api_secretkey'),
                'created_at': a['created_at'],
                'updated_at': a['updated_at'],
            })
            axim_settings_count += 1

        results['partner_axim_settings'] = axim_settings_count
        log(f"  ✅ partner_axim_settings: {axim_settings_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 7: Partner Exchange Rate Policies
        # ══════════════════════════════════════════════════════════════════
        log_phase(7, "Partner Exchange Rate Policies")

        c1.execute("SELECT * FROM partner_settings ORDER BY partner_id")
        v1_settings = c1.fetchall()

        erp_count = 0
        for ps in v1_settings:
            if DRY_RUN:
                erp_count += 1
                continue

            c2.execute("""
                INSERT INTO partner_exchange_rate_policies (
                    partner_id, rate_type, fixed_rate,
                    created_at, updated_at
                ) VALUES (
                    %(partner_id)s, %(rate_type)s, %(fixed_rate)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'partner_id': ps['partner_id'],
                'rate_type': ps['currency_exchange_rate_type'],
                'fixed_rate': ps['fixed_exchange_rate'],
                'created_at': ps['created_at'],
                'updated_at': ps['updated_at'],
            })
            erp_count += 1

        results['partner_exchange_rate_policies'] = erp_count
        log(f"  ✅ partner_exchange_rate_policies: {erp_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 7b: Partner Withdrawal Policies
        # ══════════════════════════════════════════════════════════════════
        log_phase("7b", "Partner Withdrawal Policies")

        # v1 partner_settings의 withdrawal_policy / withdrawal_manual_threshold 이관
        # v1 정책 매핑:
        #   AUTO (threshold=None)              → v2 auto_approve_threshold = NULL (무제한 자동승인)
        #   MANUAL_ABOVE_THRESHOLD (threshold)  → v2 auto_approve_threshold = threshold (threshold 이상 수동)
        #   MANUAL                             → v2 auto_approve_threshold = 0 (모두 수동)
        c1.execute("SELECT * FROM partner_settings ORDER BY partner_id")
        v1_wp_settings = c1.fetchall()

        wp_count = 0
        for ps in v1_wp_settings:
            if DRY_RUN:
                wp_count += 1
                continue

            withdrawal_policy = ps.get('withdrawal_policy', 'MANUAL')
            threshold = ps.get('withdrawal_manual_threshold')

            if withdrawal_policy == 'AUTO':
                # 무제한 자동승인
                auto_approve = None
            elif withdrawal_policy == 'MANUAL_ABOVE_THRESHOLD' and threshold is not None:
                # threshold 이상이면 수동 승인
                auto_approve = threshold
            else:
                # MANUAL — 모두 수동
                auto_approve = Decimal('0')

            c2.execute("""
                INSERT INTO partner_withdrawal_policies (
                    partner_id, auto_approve_threshold,
                    daily_limit, single_limit, address_whitelist_enabled,
                    created_at, updated_at
                ) VALUES (
                    %(partner_id)s, %(auto_approve_threshold)s,
                    %(daily_limit)s, %(single_limit)s, %(address_whitelist_enabled)s,
                    %(created_at)s, %(updated_at)s
                )
                ON DUPLICATE KEY UPDATE
                    auto_approve_threshold = VALUES(auto_approve_threshold),
                    updated_at = VALUES(updated_at)
            """, {
                'partner_id': ps['partner_id'],
                'auto_approve_threshold': auto_approve,
                'daily_limit': None,
                'single_limit': None,
                'address_whitelist_enabled': False,
                'created_at': ps['created_at'],
                'updated_at': ps['updated_at'],
            })
            wp_count += 1

        results['partner_withdrawal_policies'] = wp_count
        log(f"  ✅ partner_withdrawal_policies: {wp_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 8: Wallet Addresses (HOT + MASTER, ETH 제외)
        # ══════════════════════════════════════════════════════════════════
        log_phase(8, "Wallet Addresses (HOT + MASTER)")

        c1.execute("""
            SELECT * FROM wallet_addresses
            WHERE wallet_type IN ('HOT', 'MASTER')
              AND chain_type IN ('BSC', 'POLYGON', 'TRON')
            ORDER BY id
        """)
        v1_wallets = c1.fetchall()
        log(f"  v1 지갑 (HOT+MASTER, ETH 제외): {len(v1_wallets)}개")

        wallet_count = 0
        for w in v1_wallets:
            network_id = CHAIN_TO_NETWORK[w['chain_type']]
            hd_wallet_id = HD_WALLET_MAP.get(w['hd_wallet_id'])

            if DRY_RUN:
                WALLET_ADDR_MAP[w['id']] = w['id']  # 임시
                wallet_count += 1
                continue

            c2.execute("""
                INSERT INTO wallet_addresses (
                    address, network_id, hd_wallet_id, derivation_index, derivation_path,
                    wallet_type, partner_id, partner_user_id, status,
                    monitor_registered, created_at, updated_at
                ) VALUES (
                    %(address)s, %(network_id)s, %(hd_wallet_id)s, %(derivation_index)s, %(derivation_path)s,
                    %(wallet_type)s, %(partner_id)s, %(partner_user_id)s, %(status)s,
                    %(monitor_registered)s, %(created_at)s, %(updated_at)s
                )
            """, {
                'address': w['address'],
                'network_id': network_id,
                'hd_wallet_id': hd_wallet_id,
                'derivation_index': w['derivation_index'],
                'derivation_path': w['derivation_path'],
                'wallet_type': w['wallet_type'],
                'partner_id': w['partner_id'],
                'partner_user_id': w.get('partner_user_id'),
                'status': 'ACTIVE' if w['status'] == 'ACTIVE' else 'INACTIVE',
                'monitor_registered': 0,
                'created_at': w['created_at'],
                'updated_at': w['updated_at'],
            })
            new_id = c2.lastrowid
            WALLET_ADDR_MAP[w['id']] = new_id
            wallet_count += 1

        results['wallet_addresses'] = wallet_count
        log(f"  ✅ wallet_addresses: {wallet_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 9: Wallet Keys
        # ══════════════════════════════════════════════════════════════════
        log_phase(9, "Wallet Keys")

        c1.execute("""
            SELECT wk.* FROM wallet_private_keys wk
            INNER JOIN wallet_addresses wa ON wk.wallet_address_id = wa.id
            WHERE wa.wallet_type IN ('HOT', 'MASTER')
              AND wa.chain_type IN ('BSC', 'POLYGON', 'TRON')
        """)
        v1_keys = c1.fetchall()
        log(f"  v1 키 (HOT+MASTER, ETH 제외): {len(v1_keys)}개")

        key_count = 0
        key_fail = 0
        for k in v1_keys:
            v2_wallet_id = WALLET_ADDR_MAP.get(k['wallet_address_id'])
            if not v2_wallet_id:
                continue

            if DRY_RUN:
                key_count += 1
                continue

            # v1(AES/GCM/NoPadding, Base64) → v2(AES-256-GCM, hex) 재암호화
            try:
                v2_encrypted = re_encrypt_v1_to_v2(k['encrypted_private_key'])
            except Exception as e:
                key_fail += 1
                log(f"  ⚠️ 키 재암호화 실패 (v1 wallet_address_id={k['wallet_address_id']}): {e}")
                continue

            c2.execute("""
                INSERT INTO wallet_keys (
                    wallet_address_id, encrypted_private_key, encryption_algorithm,
                    key_version, is_active, created_at, updated_at
                ) VALUES (
                    %(wallet_address_id)s, %(encrypted_private_key)s, %(encryption_algorithm)s,
                    %(key_version)s, %(is_active)s, %(created_at)s, %(updated_at)s
                )
            """, {
                'wallet_address_id': v2_wallet_id,
                'encrypted_private_key': v2_encrypted,
                'encryption_algorithm': 'AES-256-GCM',
                'key_version': 'v2',
                'is_active': k['is_active'],
                'created_at': k['created_at'],
                'updated_at': k['updated_at'],
            })
            key_count += 1

        results['wallet_keys'] = key_count
        log(f"  ✅ wallet_keys: {key_count}건 {'(예상)' if DRY_RUN else '삽입'} (재암호화: v1→v2)")
        if key_fail > 0:
            log(f"  ⚠️ 재암호화 실패: {key_fail}건")

        # ══════════════════════════════════════════════════════════════════
        # Phase 10: Wallet Balances
        # ══════════════════════════════════════════════════════════════════
        log_phase(10, "Wallet Balances")

        c1.execute("""
            SELECT wb.*, wa.chain_type
            FROM wallet_balances wb
            INNER JOIN wallet_addresses wa ON wb.wallet_address_id = wa.id
            WHERE wa.wallet_type IN ('HOT', 'MASTER')
              AND wa.chain_type IN ('BSC', 'POLYGON', 'TRON')
        """)
        v1_balances = c1.fetchall()
        log(f"  v1 잔액 (HOT+MASTER, ETH 제외): {len(v1_balances)}개")

        balance_count = 0
        skipped_balances = 0
        for b in v1_balances:
            v2_wallet_id = WALLET_ADDR_MAP.get(b['wallet_address_id'])
            if not v2_wallet_id:
                skipped_balances += 1
                continue

            currency_key = (b['currency_type'], b['chain_type'])
            currency_id = CURRENCY_MAP.get(currency_key)
            if not currency_id:
                skipped_balances += 1
                continue

            if DRY_RUN:
                balance_count += 1
                continue

            c2.execute("""
                INSERT INTO wallet_balances (
                    wallet_address_id, currency_id, balance,
                    created_at, updated_at
                ) VALUES (
                    %(wallet_address_id)s, %(currency_id)s, %(balance)s,
                    %(created_at)s, %(updated_at)s
                )
                ON DUPLICATE KEY UPDATE
                    balance = IF(VALUES(balance) > balance, VALUES(balance), balance),
                    updated_at = VALUES(updated_at)
            """, {
                'wallet_address_id': v2_wallet_id,
                'currency_id': currency_id,
                'balance': b['balance'] or Decimal('0'),
                'created_at': b['created_at'],
                'updated_at': b['last_updated_at'] or b['created_at'],
            })
            balance_count += 1

        results['wallet_balances'] = balance_count
        log(f"  ✅ wallet_balances: {balance_count}건 {'(예상)' if DRY_RUN else '삽입'} (스킵: {skipped_balances})")

        # ══════════════════════════════════════════════════════════════════
        # Phase 11: Nonce Tracker
        # ══════════════════════════════════════════════════════════════════
        log_phase(11, "Nonce Tracker — SKIP (온체인 동기화 필요)")
        log(f"  ⏭️  v1 nonce 이관 제외 (v2에서 블록체인 실제 nonce 동기화로 시작)")

        # ══════════════════════════════════════════════════════════════════
        # Phase 12: Wallet Index Manager
        # ══════════════════════════════════════════════════════════════════
        log_phase(12, "Wallet Index Manager")

        # v2 구조: UNIQUE(hd_wallet_id, network_id, partner_id)
        # v1: wallet_type별 행 → v2: partner_id로 통합
        # HOT/MASTER를 같은 (hd_wallet, network, partner) 그룹으로 병합, MAX(current_index) 사용
        c1.execute("""
            SELECT wic.*, hw.chain_type
            FROM wallet_index_counters wic
            JOIN hd_wallets hw ON hw.chain_type = wic.chain_type
            WHERE wic.wallet_type IN ('HOT', 'MASTER')
              AND wic.chain_type IN ('BSC', 'POLYGON', 'TRON')
        """)
        v1_indices = c1.fetchall()
        log(f"  v1 wallet_index_counters (HOT+MASTER, ETH 제외): {len(v1_indices)}개")

        # 그룹핑: (chain_type, partner_id) → MAX(current_index)
        index_groups = {}
        for idx in v1_indices:
            chain = idx['chain_type']
            pid = idx.get('partner_id') or 0
            key = (chain, pid)
            cur = idx['current_index'] or 0
            if key not in index_groups or cur > index_groups[key]:
                index_groups[key] = cur

        wim_count = 0
        for (chain, pid), last_idx in index_groups.items():
            network_id = CHAIN_TO_NETWORK.get(chain)
            if not network_id:
                continue

            # v2 hd_wallet_id 조회
            hd_id = v2_hd.get(network_id)
            if not hd_id:
                continue

            if DRY_RUN:
                wim_count += 1
                continue

            c2.execute("""
                INSERT INTO wallet_index_manager (
                    hd_wallet_id, network_id, partner_id, last_index,
                    created_at, updated_at
                ) VALUES (
                    %(hd_wallet_id)s, %(network_id)s, %(partner_id)s, %(last_index)s,
                    NOW(6), NOW(6)
                )
            """, {
                'hd_wallet_id': hd_id,
                'network_id': network_id,
                'partner_id': pid,
                'last_index': last_idx,
            })
            wim_count += 1

        results['wallet_index_manager'] = wim_count
        log(f"  ✅ wallet_index_manager: {wim_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 13: External Wallets (v1 체인별 3행 → v2 JSON 1행)
        # ══════════════════════════════════════════════════════════════════
        log_phase(13, "External Wallets")

        c1.execute("""
            SELECT * FROM external_wallet_mappings
            WHERE chain_type IN ('BSC', 'POLYGON', 'TRON')
            ORDER BY partner_id, partner_user_id, connection_id, chain_type
        """)
        v1_ext_wallets = c1.fetchall()
        log(f"  v1 external_wallet_mappings (ETH 제외): {len(v1_ext_wallets)}건")

        # 그룹핑: (partner_id, partner_user_id, connection_id) → {chain_type: address, ...}
        ext_groups = {}
        for ew in v1_ext_wallets:
            key = (ew['partner_id'], ew['partner_user_id'], ew['connection_id'])
            if key not in ext_groups:
                ext_groups[key] = {
                    'connection_type': ew['connection_type'],
                    'status': ew['status'],
                    'connected_at': ew.get('connected_at'),
                    'created_at': ew['created_at'],
                    'updated_at': ew['updated_at'],
                    'addresses': {},
                }
            network_id = CHAIN_TO_NETWORK.get(ew['chain_type'])
            if network_id:
                ext_groups[key]['addresses'][str(network_id)] = ew['wallet_address']
            # 하나라도 ACTIVE면 ACTIVE
            if ew['status'] == 'ACTIVE':
                ext_groups[key]['status'] = 'ACTIVE'

        log(f"  그룹핑 결과: {len(ext_groups)}개 사용자")

        ext_count = 0
        for (pid, uid, conn_id), data in ext_groups.items():
            if DRY_RUN:
                ext_count += 1
                continue

            c2.execute("""
                INSERT INTO external_wallets (
                    partner_id, partner_user_id, connection_type, connection_id,
                    wallet_addresses, status, connected_at,
                    created_at, updated_at
                ) VALUES (
                    %(partner_id)s, %(partner_user_id)s, %(connection_type)s, %(connection_id)s,
                    %(wallet_addresses)s, %(status)s, %(connected_at)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'partner_id': pid,
                'partner_user_id': uid,
                'connection_type': data['connection_type'],
                'connection_id': conn_id,
                'wallet_addresses': json.dumps(data['addresses']),
                'status': 'CONNECTED' if data['status'] == 'ACTIVE' else 'REVOKED',
                'connected_at': data['connected_at'],
                'created_at': data['created_at'],
                'updated_at': data['updated_at'],
            })
            ext_count += 1

        results['external_wallets'] = ext_count
        log(f"  ✅ external_wallets: {ext_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 14: Deposits (CONFIRMED/SETTLED, ETH 제외)
        # ══════════════════════════════════════════════════════════════════
        log_phase(14, "Deposits")

        c1.execute("""
            SELECT * FROM transactions
            WHERE transaction_type = 'DEPOSIT'
              AND status IN ('CONFIRMED', 'SETTLED')
              AND chain_type IN ('BSC', 'POLYGON', 'TRON')
              AND currency_type = 'USDT'
              AND amount >= 0.001
            ORDER BY id
        """)
        v1_deposits = c1.fetchall()
        log(f"  v1 입금 (USDT, ≥0.001, ETH 제외): {len(v1_deposits)}개")

        deposit_count = 0
        skipped_deposits = 0
        for d in v1_deposits:
            network_id = CHAIN_TO_NETWORK[d['chain_type']]
            currency_key = (d['currency_type'], d['chain_type'])
            currency_id = CURRENCY_MAP.get(currency_key)
            if not currency_id:
                skipped_deposits += 1
                continue

            v2_wallet_id = None
            if d['wallet_address_id']:
                v2_wallet_id = WALLET_ADDR_MAP.get(d['wallet_address_id'])

            v2_status = {'CONFIRMED': 'CONFIRMED', 'SETTLED': 'SETTLED'}.get(d['status'])

            if DRY_RUN:
                DEPOSIT_MAP[d['id']] = d['id']
                deposit_count += 1
                continue

            c2.execute("""
                INSERT INTO deposits (
                    deposit_code, partner_id, partner_user_id,
                    network_id, currency_id, deposit_type, deposit_method,
                    amount, fee_amount, tx_hash,
                    from_address, to_address, block_number,
                    price_krw, price_usd,
                    wallet_address_id, status,
                    partner_confirmed, partner_confirmed_at,
                    confirmed_at, settled_at,
                    created_at, updated_at
                ) VALUES (
                    %(deposit_code)s, %(partner_id)s, %(partner_user_id)s,
                    %(network_id)s, %(currency_id)s, %(deposit_type)s, %(deposit_method)s,
                    %(amount)s, %(fee_amount)s, %(tx_hash)s,
                    %(from_address)s, %(to_address)s, %(block_number)s,
                    %(price_krw)s, %(price_usd)s,
                    %(wallet_address_id)s, %(status)s,
                    %(partner_confirmed)s, %(partner_confirmed_at)s,
                    %(confirmed_at)s, %(settled_at)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'deposit_code': generate_code('DEP'),
                'partner_id': d['partner_id'],
                'partner_user_id': d.get('partner_user_id'),
                'network_id': network_id,
                'currency_id': currency_id,
                'deposit_type': 'USER_DEPOSIT' if d.get('partner_user_id') else 'PARTNER_CHARGE',
                'deposit_method': 'HD_WALLET',
                'amount': d['amount'],
                'fee_amount': Decimal('0'),
                'tx_hash': d['tx_hash'],
                'from_address': d.get('from_address'),
                'to_address': d['to_address'],
                'block_number': d.get('block_number'),
                'price_krw': d.get('price_krw'),
                'price_usd': d.get('price_usd'),
                'wallet_address_id': v2_wallet_id,
                'status': v2_status,
                'partner_confirmed': d.get('partner_confirmed', 0),
                'partner_confirmed_at': d.get('partner_confirmed_at'),
                'confirmed_at': d.get('confirmed_at'),
                'settled_at': d.get('settled_at'),
                'created_at': d['created_at'],
                'updated_at': d['updated_at'],
            })
            DEPOSIT_MAP[d['id']] = c2.lastrowid
            deposit_count += 1

        results['deposits'] = deposit_count
        log(f"  ✅ deposits: {deposit_count}건 {'(예상)' if DRY_RUN else '삽입'} (스킵: {skipped_deposits})")

        # ══════════════════════════════════════════════════════════════════
        # Phase 15: Withdrawals (CONFIRMED, ETH 제외)
        # ══════════════════════════════════════════════════════════════════
        log_phase(15, "Withdrawals")

        c1.execute("""
            SELECT t.*
            FROM transactions t
            WHERE t.transaction_type = 'WITHDRAWAL'
              AND t.status = 'CONFIRMED'
              AND t.chain_type IN ('BSC', 'POLYGON', 'TRON')
            ORDER BY t.id
        """)
        v1_withdrawals = c1.fetchall()
        log(f"  v1 출금 (CONFIRMED, ETH 제외): {len(v1_withdrawals)}개")

        withdrawal_count = 0
        for w in v1_withdrawals:
            network_id = CHAIN_TO_NETWORK[w['chain_type']]
            currency_key = (w['currency_type'], w['chain_type'])
            currency_id = CURRENCY_MAP.get(currency_key)
            if not currency_id:
                continue

            if DRY_RUN:
                withdrawal_count += 1
                continue

            c2.execute("""
                INSERT INTO withdrawals (
                    withdrawal_code, partner_id, partner_user_id,
                    network_id, currency_id, withdrawal_type, request_source,
                    to_address, amount, tx_hash,
                    block_number, status, error_message,
                    price_krw, price_usd, confirmed_at,
                    created_at, updated_at
                ) VALUES (
                    %(withdrawal_code)s, %(partner_id)s, %(partner_user_id)s,
                    %(network_id)s, %(currency_id)s, %(withdrawal_type)s, %(request_source)s,
                    %(to_address)s, %(amount)s, %(tx_hash)s,
                    %(block_number)s, %(status)s, %(error_message)s,
                    %(price_krw)s, %(price_usd)s, %(confirmed_at)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'withdrawal_code': generate_code('WDR'),
                'partner_id': w['partner_id'],
                'partner_user_id': w.get('partner_user_id'),
                'network_id': network_id,
                'currency_id': currency_id,
                'withdrawal_type': 'USER_PAYOUT',
                'request_source': 'CONSOLE',
                'to_address': w['to_address'],
                'amount': w['amount'],
                'tx_hash': w['tx_hash'],
                'block_number': w.get('block_number'),
                'status': 'CONFIRMED',
                'error_message': w.get('error_message'),
                'price_krw': w.get('price_krw'),
                'price_usd': w.get('price_usd'),
                'confirmed_at': w.get('confirmed_at'),
                'created_at': w['created_at'],
                'updated_at': w['updated_at'],
            })
            withdrawal_count += 1

        results['withdrawals'] = withdrawal_count
        log(f"  ✅ withdrawals: {withdrawal_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 16: (SKIP) Axim Payments — v2에서 새로 연동
        # ══════════════════════════════════════════════════════════════════
        log_phase(16, "Axim Payments — SKIP (v2에서 재연동)")
        log(f"  ⏭️  v1 axim_payments 이관 제외")

        # ══════════════════════════════════════════════════════════════════
        # Phase 17: Webhook Delivery Logs
        # ══════════════════════════════════════════════════════════════════
        log_phase(17, "Webhook Delivery Logs")

        c1.execute("SELECT * FROM callback_delivery_logs ORDER BY id")
        v1_callbacks = c1.fetchall()
        log(f"  v1 callback_delivery_logs: {len(v1_callbacks)}개")

        cb_count = 0
        for cb in v1_callbacks:
            if DRY_RUN:
                cb_count += 1
                continue

            v2_status = {
                'PENDING': 'PENDING',
                'SUCCESS': 'SUCCESS',
                'FAILED': 'FAILED',
                'RETRYING': 'RETRYING',
            }.get(cb['status'], 'PENDING')

            c2.execute("""
                INSERT INTO webhook_delivery_logs (
                    partner_id, callback_type, reference_type, reference_id,
                    callback_url, request_payload, response_status, response_body,
                    status, retry_count, next_retry_at,
                    error_message, error_type, sent_at,
                    created_at, updated_at
                ) VALUES (
                    %(partner_id)s, %(callback_type)s, %(reference_type)s, %(reference_id)s,
                    %(callback_url)s, %(request_payload)s, %(response_status)s, %(response_body)s,
                    %(status)s, %(retry_count)s, %(next_retry_at)s,
                    %(error_message)s, %(error_type)s, %(sent_at)s,
                    %(created_at)s, %(updated_at)s
                )
            """, {
                'partner_id': cb['partner_id'],
                'callback_type': cb['callback_type'],
                'reference_type': cb['callback_type'],  # DEPOSIT / WITHDRAWAL
                'reference_id': cb['transaction_id'],
                'callback_url': cb['callback_url'],
                'request_payload': cb['request_payload'],
                'response_status': cb.get('response_status'),
                'response_body': cb.get('response_body'),
                'status': v2_status,
                'retry_count': cb.get('retry_count', 0),
                'next_retry_at': cb.get('next_retry_at'),
                'error_message': cb.get('error_message'),
                'error_type': cb.get('error_type'),
                'sent_at': cb.get('sent_at'),
                'created_at': cb['created_at'],
                'updated_at': cb['updated_at'],
            })
            cb_count += 1

        results['webhook_delivery_logs'] = cb_count
        log(f"  ✅ webhook_delivery_logs: {cb_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        # ══════════════════════════════════════════════════════════════════
        # Phase 18: (SKIP) Wallet Approvals — v1 ERC20_APPROVE는 v2 컨트랙트와 다르므로 제외
        # ══════════════════════════════════════════════════════════════════
        log_phase(18, "Wallet Approvals — SKIP (v2 컨트랙트 불일치)")
        log(f"  ⏭️  v1 gasless_prerequisites 이관 제외 (v2에서 새로 approve 필요)")

        # ══════════════════════════════════════════════════════════════════
        # Phase 19: Deposit Reservations — SKIP (v2 재연결 필요)
        # ══════════════════════════════════════════════════════════════════
        log_phase(19, "Deposit Reservations — SKIP")
        log("  ⏭️  SKIP: deposit_reservations는 v2에서 새로 생성. v1 예약은 만료/완료 상태이므로 이관 불필요.")
        results['deposit_reservations'] = 'SKIP'

        # ══════════════════════════════════════════════════════════════════
        # Phase 19b: Ledger Entries — 파트너별 최종 잔액 초기 CREDIT
        # ══════════════════════════════════════════════════════════════════
        log_phase('19b', "Ledger Entries (파트너 최종 잔액)")

        # MASTER 지갑의 온체인 잔액 기반 (파트너별 1:1 매핑)
        # ⚠️ 이전 버전은 HOT 잔액 기반이었으나, 이미 집금된 자산이 누락되는 문제가 있었음
        # MASTER = 파트너의 출금 원천 지갑 → 온체인 잔액 = 파트너의 실제 자산
        c2.execute("""
            SELECT wa.partner_id, wb.currency_id, c.network_id,
                   wb.balance AS total_balance
            FROM wallet_balances wb
            JOIN wallet_addresses wa ON wb.wallet_address_id = wa.id
            JOIN currencies c ON wb.currency_id = c.id
            WHERE wa.partner_id IS NOT NULL
              AND wa.wallet_type = 'MASTER'
              AND c.currency_type = 'TOKEN'
              AND wb.balance > 0
        """)
        partner_balances = c2.fetchall()
        log(f"  파트너별 잔액 그룹: {len(partner_balances)}건")

        ledger_count = 0
        for pb in partner_balances:
            if DRY_RUN:
                ledger_count += 1
                continue

            c2.execute("""
                INSERT INTO ledger_entries (
                    partner_id, currency_id, network_id,
                    entry_type, amount, balance_after,
                    reference_type, reference_id, tx_hash,
                    description, created_at
                ) VALUES (
                    %(partner_id)s, %(currency_id)s, %(network_id)s,
                    'CREDIT', %(amount)s, %(balance_after)s,
                    'MIGRATION', NULL, NULL,
                    'v1→v2 마이그레이션 초기 잔액', NOW()
                )
            """, {
                'partner_id': pb['partner_id'],
                'currency_id': pb['currency_id'],
                'network_id': pb['network_id'],
                'amount': pb['total_balance'],
                'balance_after': pb['total_balance'],
            })
            ledger_count += 1

        results['ledger_entries'] = ledger_count
        log(f"  ✅ ledger_entries: {ledger_count}건 {'(예상)' if DRY_RUN else '삽입'}")

        if not DRY_RUN:
            v2.commit()

        # ══════════════════════════════════════════════════════════════════
        # Phase 20: AUTO_INCREMENT 동기화
        # ══════════════════════════════════════════════════════════════════
        log_phase(20, "AUTO_INCREMENT 동기화")

        if not DRY_RUN:
            auto_inc_tables = [
                'partners', 'admins', 'hd_wallets', 'wallet_addresses', 'wallet_keys',
                'wallet_balances', 'deposits', 'withdrawals', 'axim_payments',
                'external_wallets', 'webhook_delivery_logs', 'partner_exchange_rate_policies',
                'ledger_entries',
            ]
            for tbl in auto_inc_tables:
                c2.execute(f"SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM {tbl}")
                next_id = c2.fetchone()['next_id']
                c2.execute(f"ALTER TABLE {tbl} AUTO_INCREMENT = {next_id}")
            log(f"  ✅ {len(auto_inc_tables)}개 테이블 AUTO_INCREMENT 동기화 완료")
        else:
            log(f"  ⏭️  DRY RUN — AUTO_INCREMENT 스킵")

        # ══════════════════════════════════════════════════════════════════
        # Phase 21: 검증
        # ══════════════════════════════════════════════════════════════════
        log_phase(21, "데이터 검증")

        verifications = [
            ('partners',                    "SELECT COUNT(*) as c FROM coin_payments.partners",
                                            "SELECT COUNT(*) as c FROM cryptoments_db.partners"),
            ('partner_chain_configs',       "SELECT COUNT(*) as c FROM coin_payments.partner_chain_activations WHERE chain_type IN ('BSC','POLYGON','TRON')",
                                            "SELECT COUNT(*) as c FROM cryptoments_db.partner_chain_configs"),
            ('partner_axim_settings',       "SELECT COUNT(*) as c FROM coin_payments.partner_axim_settings",
                                            "SELECT COUNT(*) as c FROM cryptoments_db.partner_axim_settings"),
            ('partner_exchange_rate_policies', "SELECT COUNT(*) as c FROM coin_payments.partner_settings",
                                            "SELECT COUNT(*) as c FROM cryptoments_db.partner_exchange_rate_policies"),
            ('partner_withdrawal_policies', "SELECT COUNT(*) as c FROM coin_payments.partner_settings",
                                            "SELECT COUNT(*) as c FROM cryptoments_db.partner_withdrawal_policies"),
            ('wallet_addresses (HOT+MASTER)', "SELECT COUNT(*) as c FROM coin_payments.wallet_addresses WHERE wallet_type IN ('HOT','MASTER') AND chain_type IN ('BSC','POLYGON','TRON')",
                                            "SELECT COUNT(*) as c FROM cryptoments_db.wallet_addresses WHERE wallet_type IN ('HOT','MASTER')"),
            ('wallet_keys',                 "SELECT COUNT(*) as c FROM coin_payments.wallet_private_keys wpk JOIN coin_payments.wallet_addresses wa ON wpk.wallet_address_id=wa.id WHERE wa.wallet_type IN ('HOT','MASTER') AND wa.chain_type IN ('BSC','POLYGON','TRON')",
                                            "SELECT COUNT(*) as c FROM cryptoments_db.wallet_keys"),
        ]

        log(f"  {'테이블':<35} {'V1':>8} {'V2':>8} {'일치':>6}")
        log(f"  {'─'*35} {'─'*8} {'─'*8} {'─'*6}")

        all_pass = True
        for name, q1, q2 in verifications:
            c1.execute(q1)
            v1_cnt = c1.fetchone()['c']
            if DRY_RUN:
                v2_cnt = results.get(name.split(' ')[0], '?')
                match = '(dry)'
            else:
                c2.execute(q2)
                v2_cnt = c2.fetchone()['c']
                match = '✅' if v1_cnt == v2_cnt else '❌'
                if v1_cnt != v2_cnt:
                    all_pass = False
            log(f"  {name:<35} {v1_cnt:>8} {str(v2_cnt):>8} {match:>6}")

        # ledger_entries 는 v2 신규 — v1 대응 없으므로 건수만 출력
        if DRY_RUN:
            le_cnt = results.get('ledger_entries', '?')
        else:
            c2.execute("SELECT COUNT(*) as c FROM cryptoments_db.ledger_entries")
            le_cnt = c2.fetchone()['c']
        log(f"  {'ledger_entries (v2 신규)':<35} {'N/A':>8} {str(le_cnt):>8} {'  ──':>6}")

        # ══════════════════════════════════════════════════════════════════
        # COMMIT
        # ══════════════════════════════════════════════════════════════════
        if not DRY_RUN:
            v2.commit()
            log("\n✅ 모든 마이그레이션 커밋 완료!")
        else:
            log("\n🔍 DRY RUN 완료 — 실제 변경 없음.")

        # ── 결과 요약 ────────────────────────────────────────────────────
        log("\n" + "=" * 60)
        log("마이그레이션 결과 요약")
        log("=" * 60)
        total = 0
        for name, count in results.items():
            if isinstance(count, int):
                log(f"  {name:<35} {count:>6}")
                total += count
            else:
                log(f"  {name:<35} {'SKIP':>6}")
        log(f"  {'─'*35} {'─'*6}")
        log(f"  {'총 레코드':<35} {total:>6}")

        if not DRY_RUN and all_pass:
            log("\n🎉 모든 검증 통과!")
        elif not DRY_RUN:
            log("\n⚠️  일부 검증 불일치 — 수동 확인 필요")

    except Exception as e:
        if not DRY_RUN:
            v2.rollback()
        log(f"\n❌ 에러 발생 — 롤백: {e}")
        traceback.print_exc()
    finally:
        c1.close()
        c2.close()
        v1.close()
        v2.close()


if __name__ == '__main__':
    main()
