from __future__ import annotations

import ast
import json
import os
import re
import shutil
from pathlib import Path

ROOT = Path(__file__).resolve().parent
SOURCE = (ROOT / 'ws8771_main.py').read_text(encoding='utf-8')
CONFIG = json.loads((ROOT / 'ws8771_config.json').read_text(encoding='utf-8'))


def test_ws_version_and_direct_finalize_handlers() -> None:
    assert 'WS_BUILD_VERSION = "2.3.8.238"' in SOURCE
    assert 'def handle_ig_media_exists' in SOURCE
    assert 'def handle_ig_media_download_direct' in SOURCE
    assert 'def handle_ig_media_downloaded' in SOURCE
    assert 'def generate_account_html_report' in SOURCE


def test_mouse_origin_heart_modules_are_removed() -> None:
    for name in [
        'ws8771_heart_client.py', 'ws8771_ig_auto_click_download.py',
        'ws8771_ig_auto_download.py', 'ws8771_origin_crosscheck.py'
    ]:
        assert not (ROOT / name).exists(), name
    forbidden = [
        'MOUSEEVENTF_LEFTDOWN', 'mouse_left_click(', 'mouse_move_smooth(',
        'SetCursorPos', 'GetCursorPos', 'WindowFromPoint',
        'ig_rtc_auto_click_once', 'ig_rtc_mouse_sync', 'ig_rtc_hover_status',
        'hert8772position', 'ig_dom_calibration'
    ]
    for token in forbidden:
        assert token not in SOURCE, token


def test_config_has_no_retired_mouse_state_and_auto_enter_is_enabled() -> None:
    for key in ['user_mouse_idle_resume_sec', 'user_mouse_move_threshold_px', 'ig_dom_calibration', 'hert8772position', 'mouse_action_rest_sec']:
        assert key not in CONFIG
    actions = set((CONFIG.get('thread_queue_mode') or {}).get('actions') or [])
    assert 'ig_rtc_auto_click_once' not in actions
    assert {'ig_media_exists', 'ig_media_download_direct', 'ig_media_downloaded'}.issubset(actions)
    assert 'def handle_ig_rtc_enter_auto_arm' in SOURCE
    assert CONFIG['enabled'] is True
    assert CONFIG['enter_mode'] == 'auto'
    assert CONFIG['try_enter_after_idok'] is True
    assert CONFIG['match_contains'] is True


def test_instagram_save_dialog_enter_flow_is_wired() -> None:
    for token in [
        'def instagram_download_dialog_match',
        'self.auto_enter_expected = {',
        'self.arm_auto_enter(reason, data)',
        'find_save_dialog_filename_edit(hwnd, expected)',
        'ig_match, ig_match_reason = instagram_download_dialog_match(filename, expected)',
        'if auto_unarmed and not ig_match:',
        'auto_accept_save_dialog(hwnd, self.cfg, expected)',
        'press_enter_key()',
    ]:
        assert token in SOURCE, token
    # Instagram 파일 패턴만 자동 확정하고 다른 프로그램 Save As는 건드리지 않는다.
    for token in ['_No000_', '_No없음_', 'ig_filename_pattern', 'filename_pattern_mismatch']:
        assert token in SOURCE, token

    tree = ast.parse(SOURCE)
    node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'instagram_download_dialog_match')
    scope = {'os': os, 're': re}
    exec(compile(ast.Module(body=[node], type_ignores=[]), str(ROOT / 'ws8771_main.py'), 'exec'), scope)
    match = scope['instagram_download_dialog_match']
    assert match('owner_No000_Da0WTq-OosH.mp4', {}) == (True, 'ig_filename_pattern')
    assert match('owner_No003_Da0WTq-OosH.jpg', {'shortcode': 'Da0WTq-OosH'}) == (True, 'expected_shortcode')
    assert match('report.xlsx', {}) == (False, 'not_media_extension')
    assert match('vacation.jpg', {}) == (False, 'filename_pattern_mismatch')


def test_fixed_stdown_instagram_account_storage(tmp_path: Path) -> None:
    for token in [
        'derive_stdown_root(ST_ROOT) / "instagram"',
        'def relocate_download_to_expected_folder',
        'download_relocate_failed',
        'target_path_enter',
        'enter_relocate_fallback',
        'write_control_text(edit_hwnd, target_path)',
    ]:
        assert token in SOURCE, token

    tree = ast.parse(SOURCE)
    node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'expected_download_folder')
    scope = {
        'Path': Path,
        'ST_ROOT': Path('/tmp/bundle/_St'),
        'derive_stdown_root': lambda root: Path(root).parent / '_StDown',
        'safe_path_part': lambda value, fallback='unknown': str(value or fallback),
    }
    exec(compile(ast.Module(body=[node], type_ignores=[]), str(ROOT / 'ws8771_main.py'), 'exec'), scope)
    expected = scope['expected_download_folder']({'account': 'account_a'})
    assert expected.replace('\\', '/').endswith('/_StDown/instagram/account_a')
    merged = scope['expected_download_folder']({'account': 'account_a', 'account_folder_merge': True})
    assert merged.replace('\\', '/').endswith('/_StDown/instagram')

    relocate_node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'relocate_download_to_expected_folder')
    target_root = tmp_path / '_StDown' / 'instagram' / 'account_a'
    relocate_scope = {
        'Path': Path,
        'os': os,
        'shutil': shutil,
        'expected_download_folder': lambda data: str(target_root),
        'file_hashes': lambda path: ('md5', 'sha256', Path(path).stat().st_size),
    }
    exec(compile(ast.Module(body=[relocate_node], type_ignores=[]), str(ROOT / 'ws8771_main.py'), 'exec'), relocate_scope)
    source = tmp_path / 'Downloads' / 'owner_No001_CODE1.mp4'
    source.parent.mkdir(parents=True)
    source.write_bytes(b'media')
    moved_path, moved = relocate_scope['relocate_download_to_expected_folder'](source, {'account': 'account_a'})
    assert moved is True
    assert Path(moved_path) == target_root / source.name
    assert Path(moved_path).read_bytes() == b'media'
    assert not source.exists()


def test_ws_records_shared_media_saved_after_file_finalize() -> None:
    for token in [
        "from Shared.storage import canonical_instagram_post_url, derive_stdown_root, record_downloaded_file",
        "def record_shared_media_saved",
        "canonical_instagram_post_url(",
        "MEDIA_SAVED_OK",
        "storage_ok",
        "client_event_id",
    ]:
        assert token in SOURCE, token


def test_save_dialog_state_callback_exists_for_ui_diagnostics() -> None:
    assert 'def save_dialog_state(self, is_open, source, dialogs)' in SOURCE
    assert 'save_dialog_state' in SOURCE
    # v230 field omission caused repeated Tk callback exceptions; the callback must now be concrete.
    tree = ast.parse(SOURCE)
    floating = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'FloatingApp')
    assert any(isinstance(n, ast.FunctionDef) and n.name == 'save_dialog_state' for n in floating.body)


def test_ws_direct_download_streams_to_target(tmp_path: Path) -> None:
    assert 'def download_url_to_file' in SOURCE
    assert 'DIRECT_DOWNLOAD_BEGIN' in SOURCE
    assert 'DIRECT_DOWNLOAD_OK' in SOURCE

    tree = ast.parse(SOURCE)
    node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'download_url_to_file')
    import threading
    import urllib.request
    scope = {'Path': Path, 'os': os, 'threading': threading, 'urllib': __import__('urllib')}
    exec(compile(ast.Module(body=[node], type_ignores=[]), str(ROOT / 'ws8771_main.py'), 'exec'), scope)

    source = tmp_path / 'source.mp4'
    source.write_bytes(b'instagram-media-bytes')
    target = tmp_path / '_StDown' / 'instagram' / 'account_a' / 'source.mp4'
    result = scope['download_url_to_file'](source.as_uri(), target, headers={}, timeout=5)
    assert result['ok'] is True
    assert Path(result['file_path']) == target.resolve()
    assert target.read_bytes() == b'instagram-media-bytes'
    assert not list(target.parent.glob('*.part'))


def test_feature_check_log_is_korean_concise_and_deduplicated() -> None:
    for token in [
        'feature_check_mode = bool(data.get("feature_check_mode"))',
        'check_id = str(data.get("check_id") or "").strip()',
        'check_status = str(data.get("check_status") or "").strip()',
        'check_message_ko = str(data.get("check_message_ko") or "").strip()',
        'def project_progress_check(self, feature_name, check_id, status, message_ko, detail=None):',
        '_project_progress_seen',
        '_append_project_progress_line(judgement)',
        '검색어=*** 상세로그 ***',
        'ws_project_progress_log',
        'ws_failure_log',
        'elif (not feature_check_mode) and (stage or decision or action):',
    ]:
        assert token in SOURCE, token


def test_project_progress_and_failure_logs_are_linked(tmp_path: Path, capsys) -> None:
    tree = ast.parse(SOURCE)
    function_names = {
        'project_log_context_snapshot',
        '_append_project_progress_line',
        '_project_failure_keyword',
        '_write_project_failure_excerpt',
        '_write_run_detail_marker',
        '_prune_python_logs',
    }
    nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in function_names]
    floating = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'FloatingApp')
    method = next(n for n in floating.body if isinstance(n, ast.FunctionDef) and n.name == 'project_progress_check')

    import threading
    from datetime import datetime
    from types import SimpleNamespace

    progress_path = tmp_path / 'ws_project_progress_log_test.log'
    run_path = tmp_path / 'ws_run_log_test.log'
    progress_file = progress_path.open('a', encoding='utf-8', buffering=1)
    scope = {
        'threading': threading,
        'datetime': datetime,
        'os': os,
        '_PROJECT_LOG_LOCK': threading.RLock(),
        '_PROJECT_LOG_CONTEXT': {
            'enabled': True,
            'run_id': 'TEST_RUN',
            'run_log': str(run_path),
            'error_log': str(tmp_path / 'ws_err_log_test.log'),
            'project_progress_log': str(progress_path),
            'failure_dir': str(tmp_path),
            'failure_prefix': 'ws_failure_log',
        },
        '_PROJECT_PROGRESS_FILE': progress_file,
        'now_text': lambda: '2026-07-22 12:00:00',
        'safe_path_part': lambda value, fallback='x': re.sub(r'[^0-9A-Za-z._-]+', '_', str(value or fallback)).strip('_') or fallback,
    }
    exec(compile(ast.Module(body=nodes + [method], type_ignores=[]), str(ROOT / 'ws8771_main.py'), 'exec'), scope)

    fake = SimpleNamespace()
    progress = scope['project_progress_check']
    progress(fake, '자동다운로드', 'AD-01', '[체크완료]', '자동 시작 활성화', {'cycle_id': 'run_1'})
    progress(fake, '자동다운로드', 'AD-01', '[체크완료]', '자동 시작 활성화', {'cycle_id': 'run_1'})
    progress(fake, '자동다운로드', 'AD-03', '[체크실패]', '대상 불일치', {'cycle_id': 'No001_CODE', 'error_code': 'target_mismatch'})
    progress(fake, '자동다운로드', 'AD-06', '[체크완료]', '파일 저장 완료', {'cycle_id': 'No001_CODE'})
    result = progress(fake, '서버등록', 'AD-06-S01', '[체크실패]', 'HTTP 500 / 다음점검=media_insert', {
        'cycle_id': 'No001_CODE',
        'shortcode': 'CODE',
        'asset_kind': 'video',
        'error_code': 'HTTP 500',
        'next_check': 'media_insert',
        'blocking': False,
    })
    progress_file.close()

    text = progress_path.read_text(encoding='utf-8')
    assert text.count('[자동다운로드][AD-01][체크완료]') == 1
    assert '[자동다운로드][AD-03][복구완료]' in text
    assert '[서버등록][AD-06-S01][체크실패]' in text
    assert '다음단계=로컬처리계속·실패항목별도추적' in text
    assert f'상세로그={run_path}' in text
    assert '검색어=*** 상세로그 *** PP-AD-06-S01-' in text
    failure_path = Path(result['failure_log'])
    assert failure_path.exists()
    failure_text = failure_path.read_text(encoding='utf-8')
    assert f'원본 상세로그: {run_path}' in failure_text
    assert '검색어: *** 상세로그 *** PP-AD-06-S01-' in failure_text
    captured = capsys.readouterr().out
    assert '*** 상세로그 *** PP-AD-06-S01-' in captured
