"""
실사용 시나리오 통합 검증.

GUI 없이 비즈니스 로직 + DB + 알림 + API + 백업 + 마이그레이션을 시나리오별로 검증.
실패 시 어떤 시나리오가 깨졌는지 명확히 출력.
"""
from __future__ import annotations
import os
import sys
import shutil
import tempfile
import sqlite3
from datetime import date, datetime, timedelta
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

# 테스트 중에는 공휴일 자동 동기화(백그라운드 네트워크 스레드)를 비활성화.
# 이 스레드가 SQLite 연결을 잡고 있으면 임시 DB의 os.remove가 WinError 32(파일 사용 중)로
# 실패함 (S2/S31 등). DB 인스턴스 생성 전에 설정해야 효과 있음.
os.environ.setdefault('CLOCKOUT_DISABLE_HOLIDAY_SYNC', '1')

PASS = []
FAIL = []
WARN = []


def case(label):
    """테스트 케이스 데코레이터."""
    def deco(fn):
        def wrapper(*args, **kwargs):
            try:
                fn(*args, **kwargs)
                PASS.append(label)
                print(f"[PASS] {label}")
            except AssertionError as e:
                FAIL.append((label, str(e)))
                print(f"[FAIL] {label}: {e}")
            except Exception as e:
                FAIL.append((label, f"{type(e).__name__}: {e}"))
                print(f"[ERROR] {label}: {type(e).__name__}: {e}")
        return wrapper
    return deco


def fresh_db(name='_test') -> 'Database':
    from core.database import Database
    p = os.path.join(tempfile.gettempdir(), f'clockout_{name}.db')
    if os.path.exists(p):
        os.remove(p)
    return Database(p)


# ============================================================
# 시나리오 1: 신규 사용자 첫 실행 — 마이그레이션 + 기본값
# ============================================================
@case("S1. 신규 DB: 모든 기본값 설정 키가 채워짐")
def s1_fresh_install():
    db = fresh_db('s1')
    expected_keys = ['work_hours', 'work_minutes', 'lunch_duration_minutes',
                     'dinner_duration_minutes', 'auto_lunch', 'theme',
                     'notification_clock_out', 'notification_lunch',
                     'notification_overtime', 'notification_health',
                     'annual_leave_total', 'annual_leave_days',
                     'workday_boundary_hour', 'overtime_unit', 'time_format']
    for k in expected_keys:
        v = db.get_setting(k)
        assert v is not None, f"missing default: {k}"
    assert db.get_work_minutes() == 480
    assert db.get_setting('annual_leave_keys_migrated') == 'true'


@case("S2. 레거시 DB(work_hours만) → work_minutes 자동 마이그레이션")
def s2_migration():
    p = os.path.join(tempfile.gettempdir(), 'clockout_legacy.db')
    if os.path.exists(p): os.remove(p)
    conn = sqlite3.connect(p)
    conn.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT, updated_at TIMESTAMP)")
    conn.execute("INSERT INTO settings VALUES ('work_hours', '8', CURRENT_TIMESTAMP)")
    conn.execute("INSERT INTO settings VALUES ('lunch_duration', '1', CURRENT_TIMESTAMP)")
    conn.execute("INSERT INTO settings VALUES ('annual_leave_total', '15', CURRENT_TIMESTAMP)")
    conn.commit()
    conn.close()

    from core.database import Database
    db = Database(p)
    assert db.get_setting('work_minutes') == '480'
    assert db.get_setting('lunch_duration_minutes') == '60'
    # 양쪽 동기화
    assert db.get_setting('annual_leave_days') == '15'
    os.remove(p)


# ============================================================
# 시나리오 3-6: 다양한 근무 패턴
# ============================================================
@case("S3. 단축근무 7h30m + 점심30m → 09:00 출근 → 17:00 퇴근")
def s3_short_work():
    from core.time_calculator import TimeCalculator
    calc = TimeCalculator(work_minutes=450, lunch_duration_minutes=30)
    ci = datetime(2026, 4, 29, 9, 0)
    co = calc.calculate_clock_out_time(ci, include_lunch=True)
    assert co == datetime(2026, 4, 29, 17, 0), f"got {co}"


@case("S4. 표준 8h + 점심60m → 09:00 → 18:00, 1h35m 연장 → 90분 적립")
def s4_standard():
    from core.time_calculator import TimeCalculator
    calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60)
    ci = datetime(2026, 4, 29, 9, 0)
    co = calc.calculate_clock_out_time(ci, include_lunch=True)
    assert co == datetime(2026, 4, 29, 18, 0)
    actual = co + timedelta(hours=1, minutes=35)
    a, e = calc.calculate_overtime(ci, actual, include_lunch=True)
    assert a == 95 and e == 90


@case("S5. 반일 4시간 + 점심없음 → 09:00 → 13:00")
def s5_half_day():
    from core.time_calculator import TimeCalculator
    calc = TimeCalculator(work_minutes=240, lunch_duration_minutes=0)
    ci = datetime(2026, 4, 29, 9, 0)
    co = calc.calculate_clock_out_time(ci, include_lunch=False)
    assert co == datetime(2026, 4, 29, 13, 0)


@case("S6. 야근 (저녁 적용): 8h + 점심60m + 저녁60m → 09:00 → 19:00")
def s6_dinner():
    from core.time_calculator import TimeCalculator
    calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60, dinner_duration_minutes=60)
    ci = datetime(2026, 4, 29, 9, 0)
    co = calc.calculate_clock_out_time(ci, include_lunch=True, include_dinner=True)
    assert co == datetime(2026, 4, 29, 19, 0)


@case("S7. 외출 30분 추가 시 퇴근 시각 30분 뒤로")
def s7_break():
    from core.time_calculator import TimeCalculator
    calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60)
    ci = datetime(2026, 4, 29, 9, 0)
    co_no = calc.calculate_clock_out_time(ci, include_lunch=True)
    co_break = calc.calculate_clock_out_time(ci, include_lunch=True, break_minutes=30)
    assert (co_break - co_no) == timedelta(minutes=30)


# ============================================================
# 시나리오 8-10: 연장근무 절삭/잔액
# ============================================================
@case("S8. 연장근무 30분 절삭: 29분→0, 30분→30, 35분→30, 60분→60")
def s8_truncation():
    from core.time_calculator import TimeCalculator
    calc = TimeCalculator(work_hours=8)
    ci = datetime(2026, 4, 29, 9, 0)
    base_co = ci + timedelta(hours=8)
    cases = [(29, 0), (30, 30), (35, 30), (60, 60), (89, 60), (90, 90)]
    for actual_min, expected_earned in cases:
        co = base_co + timedelta(minutes=actual_min)
        _, earned = calc.calculate_overtime(ci, co, include_lunch=False)
        assert earned == expected_earned, f"{actual_min}min → {earned}, expected {expected_earned}"


@case("S9. 연장근무 잔액: bank - usage 합산")
def s9_balance():
    db = fresh_db('s9')
    today = date.today().isoformat()
    db.add_initial_overtime_balance(180)  # 3시간 적립
    bal = db.get_total_overtime_balance()
    assert bal == 180, f"after add 3h: {bal}"
    db.add_overtime_usage(today, 60, '1h 사용')
    bal = db.get_total_overtime_balance()
    assert bal == 120, f"after use 1h: {bal}"


# ============================================================
# 시나리오 10-11: 연차
# ============================================================
@case("S10. 단축근무자 1일 연차 = 분 단위 정확 환산 (7h30m → 450분)")
def s10_leave_minutes():
    db = fresh_db('s10')
    db.save_settings({'work_minutes': 450})
    today = date.today().isoformat()
    db.add_leave_record(today, 'annual', 1.0)
    assert db.get_today_leave_minutes() == 450


@case("S11. 반차/반반차: 0.5일 → 4시간, 0.25일 → 2시간 (8h 기준)")
def s11_half_leave():
    db = fresh_db('s11')
    today = date.today().isoformat()
    # 8h 근무자 기본 (work_minutes=480)
    db.add_leave_record(today, 'half_am', 0.5)
    assert db.get_today_leave_minutes() == 240
    # 0.25 추가하여 누적
    db.add_leave_record(today, 'quarter', 0.25)
    assert db.get_today_leave_minutes() == 360  # 0.75 * 480


# ============================================================
# 시나리오 12: 공휴일/주말
# ============================================================
@case("S12. 주말 근무: 모든 시간이 연장근무로 인정 (TimeCalculator.is_weekend)")
def s12_weekend():
    from core.time_calculator import TimeCalculator
    calc = TimeCalculator()
    sat = datetime(2026, 5, 2, 9, 0)  # Saturday
    assert calc.is_weekend(sat)
    mon = datetime(2026, 5, 4, 9, 0)
    assert not calc.is_weekend(mon)
    assert calc.get_day_type(sat) == 'weekend'


@case("S13. 공휴일 자동 등록 (holidays 패키지 사용 가능 시)")
def s13_holidays():
    db = fresh_db('s13')
    n = db.add_korean_holidays_auto(2026)
    if n == -1:
        WARN.append("S13: holidays 패키지 미설치 — fallback 동작 OK")
        # fallback도 가능한지 확인
        db.add_korean_holidays(2026)
        hols = db.get_holidays_by_year(2026)
        assert len(hols) >= 8, f"fixed holidays: {len(hols)}"
    else:
        hols = db.get_holidays_by_year(2026)
        assert n >= 15, f"only {n} added (expected music holidays included)"
        # 음력 명절 포함 확인
        names = [h['name'] for h in hols]
        has_lunar = any('설날' in nm or '추석' in nm for nm in names)
        assert has_lunar, f"missing lunar holidays: {names[:5]}"


# ============================================================
# 시나리오 14-15: 알림 가드
# ============================================================
@case("S14. 알림 가드: notification_clock_out=false → 30분 전 알림 안 옴")
def s14_notif_off():
    from core.notifier import Notifier
    db = fresh_db('s14')
    db.set_setting('notification_clock_out', 'false')
    n = Notifier(db=db)
    fired = []
    n.notification_signal.connect(lambda t, m: fired.append((t, m)))
    n.check_clock_out_soon(datetime.now() + timedelta(minutes=20), datetime.now())
    assert len(fired) == 0, f"should be guarded: {fired}"


@case("S15. 알림 가드: notification_clock_out=true → 알림 발생")
def s15_notif_on():
    from core.notifier import Notifier
    db = fresh_db('s15')
    db.set_setting('notification_clock_out', 'true')
    n = Notifier(db=db)
    fired = []
    n.notification_signal.connect(lambda t, m: fired.append((t, m)))
    n.check_clock_out_soon(datetime.now() + timedelta(minutes=20), datetime.now())
    assert len(fired) == 1
    assert '퇴근' in fired[0][0]


@case("S16. 건강 경고: 3일 연속 연장근무 시에만 fire (notified_health flag)")
def s16_health():
    from core.notifier import Notifier
    db = fresh_db('s16')
    n = Notifier(db=db)
    fired = []
    n.notification_signal.connect(lambda t, m: fired.append((t, m)))
    n.notify_health_warning(2)  # 미달
    assert len(fired) == 0
    n.notify_health_warning(3)  # 발화
    assert len(fired) == 1
    n.notify_health_warning(5)  # 같은 날 중복 안 됨
    assert len(fired) == 1


@case("S17. consecutive_overtime_days: 연속 연장근무 카운트")
def s17_consecutive():
    db = fresh_db('s17')
    today = date.today()
    # 3일 연속 연장 (오늘부터 거꾸로)
    for i in range(3):
        d = (today - timedelta(days=i)).isoformat()
        rid = db.add_work_record(d, '09:00:00')
        db.update_clock_out(d, '20:00:00', total_hours=11.0,
                            overtime_minutes=120, overtime_earned=120)
    # 4일 전엔 미연장
    d4 = (today - timedelta(days=3)).isoformat()
    db.add_work_record(d4, '09:00:00')
    db.update_clock_out(d4, '18:00:00', total_hours=8.0,
                        overtime_minutes=0, overtime_earned=0)
    assert db.get_consecutive_overtime_days() == 3


# ============================================================
# 시나리오 18-19: 백업
# ============================================================
@case("S18. 백업: 첫 호출 시 파일 생성, 같은 날 두번째 호출은 skip")
def s18_backup():
    from utils.backup import backup_db_if_needed
    db = fresh_db('s18')
    bdir = Path(tempfile.gettempdir()) / 'clockout_test_backups'
    if bdir.exists():
        shutil.rmtree(bdir)
    src = db.db_path

    r1 = backup_db_if_needed(db, source_path=src, backup_dir=bdir, keep=3)
    assert r1 is not None and r1.exists()
    r2 = backup_db_if_needed(db, source_path=src, backup_dir=bdir, keep=3)
    assert r2 is None  # 같은 날
    shutil.rmtree(bdir)


@case("S19. 백업 회전: keep=3 초과 시 오래된 파일 삭제")
def s19_backup_rotate():
    from utils.backup import _rotate
    bdir = Path(tempfile.gettempdir()) / 'clockout_rotate'
    if bdir.exists():
        shutil.rmtree(bdir)
    bdir.mkdir(parents=True)
    # 5개 백업 가짜 생성
    for i in range(5):
        f = bdir / f'database-2026-04-{20+i:02d}.db'
        f.write_text('dummy')
        # 다른 mtime 부여
        ts = (datetime.now() - timedelta(days=5-i)).timestamp()
        os.utime(f, (ts, ts))

    _rotate(bdir, keep=3)
    remaining = list(bdir.glob('database-*.db'))
    assert len(remaining) == 3, f"after rotate: {len(remaining)}"
    # 최신 3개만 남았는지
    names = sorted(r.name for r in remaining)
    assert '2026-04-22' in names[0]  # 오래된 2개 삭제
    shutil.rmtree(bdir)


# ============================================================
# 시나리오 23-24: 설정 동기화
# ============================================================
@case("S23. save_settings: work_minutes만 보내도 work_hours 자동 동기화 (floor)")
def s23_sync_work():
    db = fresh_db('s23')
    db.save_settings({'work_minutes': 450})
    assert db.get_setting('work_hours') == '7'  # floor(7.5)
    assert db.get_setting('work_minutes') == '450'


@case("S24. save_settings: annual_leave_days ↔ annual_leave_total 양방향")
def s24_sync_leave():
    db = fresh_db('s24')
    db.save_settings({'annual_leave_days': 20})
    assert db.get_setting('annual_leave_total') == '20'
    db2 = fresh_db('s24b')
    db2.save_settings({'annual_leave_total': 18})
    assert db2.get_setting('annual_leave_days') == '18'


@case("S25. get_setting_int/float/bool 헬퍼: 잘못된 값 → default")
def s25_setting_helpers():
    db = fresh_db('s25')
    db.set_setting('valid_int', '42')
    db.set_setting('invalid_int', 'abc')
    db.set_setting('truthy', 'yes')
    db.set_setting('falsy', 'no')
    assert db.get_setting_int('valid_int') == 42
    assert db.get_setting_int('invalid_int', 99) == 99
    assert db.get_setting_int('missing', 7) == 7
    assert db.get_setting_bool('truthy') == True
    assert db.get_setting_bool('falsy') == False


# ============================================================
# 시나리오 26: i18n
# ============================================================
@case("S26. i18n: ko ↔ en 전환, 미번역 키 fallback")
def s26_i18n():
    from core.i18n import tr, set_language
    set_language('ko')
    assert '저장' in tr('btn.save')
    set_language('en')
    assert 'Save' in tr('btn.save')
    # 존재하지 않는 키
    assert tr('missing.key') == 'missing.key'
    # 포맷 인자
    set_language('ko')
    msg = tr('notif.clock_out_soon.body', minutes=15)
    assert '15' in msg
    # 영어로도 포맷 인자 동작
    set_language('en')
    msg_en = tr('notif.clock_out_soon.body', minutes=15)
    assert '15' in msg_en and 'minutes' in msg_en
    # 사전에 정의된 새 키들도 한국어/영어 둘 다 OK
    set_language('en')
    assert tr('menu.stats') == 'Stats'
    set_language('ko')
    assert tr('menu.stats') == '통계'



# ============================================================
# 시나리오 28-30: 에지 케이스
# ============================================================
@case("S28. work_minutes=0 입력 → settings_view가 거부 (UI 단계, 비즈니스 로직 직접 호출은 허용)")
def s28_zero_work():
    from core.time_calculator import TimeCalculator
    # TimeCalculator는 0도 허용 (UI에서 검증)
    calc = TimeCalculator(work_minutes=0, lunch_duration_minutes=0)
    ci = datetime(2026, 4, 29, 9, 0)
    co = calc.calculate_clock_out_time(ci, include_lunch=False)
    # 0 근무 → 출근 즉시 퇴근
    assert co == ci


@case("S29. 자정 넘김 외출 시간 계산 (break_in이 다음 날)")
def s29_midnight_break():
    # break 시간 계산은 main_window 내부 로직이라 직접 검증 어려움
    # 대신 datetime 비교 로직만 빠르게 확인
    break_out = datetime(2026, 4, 29, 23, 30)
    break_in = datetime(2026, 4, 30, 0, 30)
    # 같은 날짜로 잘못 만들면 음수가 나옴
    same_day_in = datetime(2026, 4, 29, 0, 30)
    if same_day_in < break_out:
        same_day_in += timedelta(days=1)
    assert (same_day_in - break_out) == timedelta(hours=1)


@case("S30. holidays 양력만 fallback: add_korean_holidays 8개 등록")
def s30_holidays_fallback():
    db = fresh_db('s30')
    db.add_korean_holidays(2026)
    hols = db.get_holidays_by_year(2026)
    assert len(hols) == 8


# ============================================================
# 시나리오 31-33: 마이그레이션 idempotency
# ============================================================
@case("S31. annual_leave_keys_migrated sentinel: 두번째 init은 skip")
def s31_migration_idempotent():
    p = os.path.join(tempfile.gettempdir(), 'clockout_idem.db')
    if os.path.exists(p): os.remove(p)
    from core.database import Database
    db1 = Database(p)
    assert db1.get_setting('annual_leave_keys_migrated') == 'true'
    # 다시 init — sentinel이 있으면 스킵
    db2 = Database(p)
    assert db2.get_setting('annual_leave_keys_migrated') == 'true'
    os.remove(p)


@case("S32. UI imports: 모든 view 모듈 정상 import")
def s32_ui_imports():
    # PyQt5 의존이라 헤드리스에선 import만 확인
    from ui import main_window, settings_view, calendar_view, help_view
    from ui import mini_widget, chart_widget, stats_view, break_view
    from ui import overtime_view, leave_view, clock_in_dialog, styles


@case("S33. 단축근무 사용자 종합: 7h30m 근무 + 30분 연장 시 = 적립 0분 (절삭)")
def s33_short_overtime():
    from core.time_calculator import TimeCalculator
    calc = TimeCalculator(work_minutes=450, lunch_duration_minutes=30)
    ci = datetime(2026, 4, 29, 9, 0)
    base_co = calc.calculate_clock_out_time(ci, include_lunch=True)  # 17:00
    # 17:29 퇴근 → 29분 연장 → 0분 적립
    actual_29 = base_co + timedelta(minutes=29)
    _, e29 = calc.calculate_overtime(ci, actual_29, include_lunch=True)
    assert e29 == 0
    # 17:30 퇴근 → 30분 연장 → 30분 적립
    actual_30 = base_co + timedelta(minutes=30)
    _, e30 = calc.calculate_overtime(ci, actual_30, include_lunch=True)
    assert e30 == 30


@case("S34. format_hours_minutes: 다양한 분 입력 변환")
def s34_format():
    from utils.time_format import format_hours_minutes
    assert format_hours_minutes(0) == '0시간 0분'
    assert format_hours_minutes(0, omit_zero_minutes=True) == '0분'
    assert format_hours_minutes(60, omit_zero_minutes=True) == '1시간'
    assert format_hours_minutes(90) == '1시간 30분'
    assert format_hours_minutes(-90) == '1시간 30분'  # abs
    assert format_hours_minutes(450) == '7시간 30분'


@case("S35. lock_detector: 정상 환경에선 False (현재 PC 잠겨있지 않음)")
def s35_lock():
    from utils.lock_detector import is_screen_locked
    # 헤드리스/non-Windows일 수도 있으니 결과만 확인
    result = is_screen_locked()
    assert result in (True, False)
    # 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트)


# ============================================================
# 시나리오 36-50: v2.3+ 신규 기능 (온보딩, Discord, 급여, 목표, CSV, 알림 dedupe 등)
# ============================================================

@case("S36. 신규 DB는 onboarding_completed = 'false' (위저드 강제)")
def s36_onboarding_new():
    db = fresh_db('s36')
    assert db.get_setting('onboarding_completed') == 'false'


@case("S37. 기존 사용자 (work_records 있음) → 자동 완료")
def s37_onboarding_existing():
    db = fresh_db('s37')
    # work_record 1건 추가 후 마이그레이션 재실행
    today = date.today().isoformat()
    db.add_work_record(today, '09:00:00', is_manual=True)
    # 다시 호출 (init_database에서 호출되는 헬퍼)
    db.migrate_v23_onboarding_for_existing()
    assert db.get_setting('onboarding_completed') == 'true'


@case("S38. salary.estimate_pay: 시급 0원 → 모두 0")
def s38_salary_zero_wage():
    from core.salary import estimate_pay
    out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
    assert out['base'] == 0 and out['overtime'] == 0 and out['total'] == 0


@case("S39. salary.estimate_pay: 8h 근무 + 30분 연장, 시급 10000 → base 75000 + ot 7500")
def s39_salary_basic():
    from core.salary import estimate_pay
    out = estimate_pay(
        [{'total_hours': 8.0, 'overtime_minutes': 30}],
        hourly_wage=10000,
        overtime_rate=1.5,
    )
    # 정규 = 8 - 0.5 = 7.5h × 10000 = 75000
    # 연장 = 0.5h × 10000 × 1.5 = 7500
    assert abs(out['base'] - 75000) < 0.01, out['base']
    assert abs(out['overtime'] - 7500) < 0.01, out['overtime']
    assert abs(out['total'] - 82500) < 0.01


@case("S40. salary.format_won: 콤마 + '원' 접미사")
def s40_format_won():
    from core.salary import format_won
    assert format_won(0) == '0원'
    assert format_won(1234567) == '1,234,567원'
    assert format_won(82500.4) == '82,500원'


@case("S41. CSV 가져오기: 표준 포맷 파싱")
def s41_csv_parse():
    from utils.csv_importer import parse_csv
    p = os.path.join(tempfile.gettempdir(), 'clockout_test.csv')
    with open(p, 'w', encoding='utf-8') as f:
        f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
        f.write("2026-04-01,09:00,18:00,60,첫째날\n")
        f.write("2026-04-02,09:30:00,17:30:00,30,단축\n")
    rows = parse_csv(p)
    os.remove(p)
    assert len(rows) == 2
    assert rows[0]['clock_in'] == '09:00:00'  # 정규화 (HH:MM → HH:MM:SS)
    assert rows[0]['lunch_minutes'] == 60
    assert rows[1]['lunch_minutes'] == 30
    assert rows[1]['memo'] == '단축'


@case("S42. CSV 검증 실패: 잘못된 날짜 형식 → ValueError")
def s42_csv_invalid():
    from utils.csv_importer import parse_csv
    p = os.path.join(tempfile.gettempdir(), 'clockout_bad.csv')
    with open(p, 'w', encoding='utf-8') as f:
        f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
        f.write("not-a-date,09:00,18:00,60,\n")
    try:
        parse_csv(p)
        assert False, "should have raised"
    except ValueError:
        pass
    finally:
        os.remove(p)


@case("S43. CSV import on_conflict='skip': 기존 일자는 건너뜀")
def s43_csv_skip():
    from utils.csv_importer import parse_csv, import_records
    db = fresh_db('s43')
    # 기존 레코드 1건
    db.add_work_record('2026-04-01', '08:30:00', is_manual=True)

    p = os.path.join(tempfile.gettempdir(), 'clockout_dup.csv')
    with open(p, 'w', encoding='utf-8') as f:
        f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
        f.write("2026-04-01,09:00,18:00,60,중복\n")
        f.write("2026-04-02,09:00,18:00,60,신규\n")

    rows = parse_csv(p)
    added, updated, skipped = import_records(db, rows, on_conflict='skip')
    os.remove(p)
    assert added == 1 and updated == 0 and skipped == 1, (added, updated, skipped)
    # 기존 레코드 보존 확인 (08:30 그대로)
    rec = db.get_work_record('2026-04-01')
    assert rec['clock_in'] == '08:30:00'


@case("S44. CSV import on_conflict='overwrite': 기존 일자 덮어씀")
def s44_csv_overwrite():
    from utils.csv_importer import parse_csv, import_records
    db = fresh_db('s44')
    db.add_work_record('2026-04-01', '08:30:00', is_manual=True)

    p = os.path.join(tempfile.gettempdir(), 'clockout_ov.csv')
    with open(p, 'w', encoding='utf-8') as f:
        f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
        f.write("2026-04-01,09:00,18:00,60,덮어쓰기\n")

    rows = parse_csv(p)
    added, updated, skipped = import_records(db, rows, on_conflict='overwrite')
    os.remove(p)
    assert updated == 1 and added == 0
    rec = db.get_work_record('2026-04-01')
    assert rec['clock_in'] == '09:00:00'


@case("S45. notification_log dedupe: 같은 (channel, event_type) 같은 날 1회")
def s45_notification_dedupe():
    db = fresh_db('s45')
    assert not db.has_notification_today('discord', 'weekly_report')
    db.log_notification('discord', 'weekly_report', payload='test', success=True)
    assert db.has_notification_today('discord', 'weekly_report')
    # 다른 event_type은 별개
    assert not db.has_notification_today('discord', 'clock_in')


@case("S46. add_meal_record: 12:00-13:00 → 60분 누적")
def s46_meal_record():
    db = fresh_db('s46')
    today = date.today().isoformat()
    # 오늘이 아닌 날짜로 add (work_record 미존재 OK)
    db.add_meal_record('2026-04-01', '12:00:00', '13:00:00', meal_type='lunch')
    db.add_meal_record('2026-04-01', '18:30:00', '19:00:00', meal_type='dinner')
    # get_meal_minutes_today은 오늘만 → 일반화된 검증은 SQL로
    conn = db.get_connection()
    cur = conn.cursor()
    cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='lunch'",
                ('2026-04-01',))
    assert cur.fetchone()[0] == 60
    cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='dinner'",
                ('2026-04-01',))
    assert cur.fetchone()[0] == 30
    conn.close()


@case("S47. crash_log 자동 생성 + 기록")
def s47_crash_log():
    from utils.crash_handler import _log_crash
    db = fresh_db('s47')
    _log_crash(db, 'TestException', 'sample message', 'Traceback ...', 'v2.6.0')
    conn = db.get_connection()
    cur = conn.cursor()
    cur.execute("SELECT COUNT(*) FROM crash_log WHERE exception_type = 'TestException'")
    assert cur.fetchone()[0] == 1
    cur.execute("SELECT message, app_version FROM crash_log WHERE exception_type = 'TestException'")
    msg, ver = cur.fetchone()
    assert msg == 'sample message' and ver == 'v2.6.0'
    conn.close()


@case("S48. updater_client.is_newer: 정확한 semver 비교")
def s48_updater_compare():
    from utils.updater_client import is_newer
    assert is_newer('v2.7.0', '2.6.0')
    assert is_newer('2.6.1', 'v2.6.0')
    assert not is_newer('v2.6.0', 'v2.6.0')
    assert not is_newer('v2.5.0', 'v2.6.0')
    assert is_newer('v2.10.0', 'v2.9.99')  # 자릿수 비교가 아니라 정수 비교


@case("S49. Discord webhook URL 비어있으면 silent False (네트워크 안 탐)")
def s49_discord_empty():
    from utils.discord_webhook import send, send_test
    assert send('', 't', 'd') is False
    assert send('http://invalid', 't', 'd') is False  # https:// 아님
    assert send_test('') is False


@case("S50. Goal 설정: 0 = 비활성 / 양수 = 활성")
def s50_goals():
    db = fresh_db('s50')
    # 기본값 확인 (0)
    assert db.get_setting_int('goal_overtime_max_monthly', 0) == 0
    assert db.get_setting_int('goal_avg_hours_daily', 0) == 0
    # 활성화
    db.save_settings({'goal_overtime_max_monthly': 1200, 'goal_avg_hours_daily': 8})
    assert db.get_setting_int('goal_overtime_max_monthly') == 1200
    assert db.get_setting_int('goal_avg_hours_daily') == 8


@case("S51. 글꼴 크기 / 고대비 설정 키")
def s51_accessibility_keys():
    db = fresh_db('s51')
    # 기본값
    assert db.get_setting_float('font_scale', 1.0) == 1.0
    assert db.get_setting_bool('high_contrast', False) is False
    # 변경
    db.save_settings({'font_scale': 1.5, 'high_contrast': True})
    assert db.get_setting_float('font_scale') == 1.5
    assert db.get_setting_bool('high_contrast') is True


@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
def s52b_planned_leave():
    db = fresh_db('s52b')
    db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
    assert db.has_full_day_leave('2026-05-15')
    assert db.get_leave_minutes_for('2026-05-15') == 480
    # 다른 날엔 영향 없음
    assert not db.has_full_day_leave('2026-05-16')


@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
def s52c_recurring_leave():
    db = fresh_db('s52c')
    db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
    # 2026-05-01 = Friday
    assert db.get_leave_minutes_for('2026-05-01') == 240
    # Monday
    assert db.get_leave_minutes_for('2026-05-04') == 0
    # 종일 아님
    assert not db.has_full_day_leave('2026-05-01')


@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
def s52d_effective():
    from core.time_calculator import TimeCalculator
    db = fresh_db('s52d')
    db.add_leave_record('2026-05-15', '오전반차', 0.5)
    calc = TimeCalculator(work_minutes=480)
    target = datetime(2026, 5, 15)
    assert calc.effective_work_minutes(target, db) == 240
    # 다른 날엔 변화 없음
    other = datetime(2026, 5, 16)
    assert calc.effective_work_minutes(other, db) == 480


@case("S52E. effective_work_minutes: 종일 연차 시 0")
def s52e_full_day():
    from core.time_calculator import TimeCalculator
    db = fresh_db('s52e')
    db.add_leave_record('2026-05-15', '연차', 1.0)
    calc = TimeCalculator(work_minutes=480)
    assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0


@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
def s52a_holiday_hotpath():
    """update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
    from core.time_calculator import TimeCalculator
    db = fresh_db('s52a')
    holiday_date = '2026-05-01'  # 근로자의 날
    db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)

    calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
    ci = datetime(2026, 5, 1, 9, 0)
    # 휴일 인식
    assert calc.is_non_working_day(ci, db)
    assert calc.get_day_type(ci, db) == 'holiday'

    # 출근 1분 후: 적립 0 (30분 단위 절삭)
    now1 = ci + timedelta(minutes=1)
    actual, earned = calc.calculate_holiday_overtime(ci, now1)
    assert actual == 1 and earned == 0
    # 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
    now30 = ci + timedelta(minutes=30)
    actual, earned = calc.calculate_holiday_overtime(ci, now30)
    assert actual == 30 and earned == 30


@case("S52. CSV import + overtime 적립까지 정상 동작")
def s52_csv_overtime():
    from utils.csv_importer import parse_csv, import_records
    db = fresh_db('s52')
    p = os.path.join(tempfile.gettempdir(), 'clockout_ot.csv')
    with open(p, 'w', encoding='utf-8') as f:
        f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
        # 8h 근무 + 점심 60m + 90분 연장 → 90분 적립 예상
        f.write("2026-04-01,09:00,19:30,60,연장근무\n")
    rows = parse_csv(p)
    added, _, _ = import_records(db, rows, on_conflict='skip')
    os.remove(p)
    assert added == 1
    bal = db.get_total_overtime_balance()
    assert bal == 90, f"overtime balance: {bal}"


# ============================================================
# Run all
# ============================================================
def main():
    s1_fresh_install()
    s2_migration()
    s3_short_work()
    s4_standard()
    s5_half_day()
    s6_dinner()
    s7_break()
    s8_truncation()
    s9_balance()
    s10_leave_minutes()
    s11_half_leave()
    s12_weekend()
    s13_holidays()
    s14_notif_off()
    s15_notif_on()
    s16_health()
    s17_consecutive()
    s18_backup()
    s19_backup_rotate()
    s23_sync_work()
    s24_sync_leave()
    s25_setting_helpers()
    s26_i18n()
    s28_zero_work()
    s29_midnight_break()
    s30_holidays_fallback()
    s31_migration_idempotent()
    s32_ui_imports()
    s33_short_overtime()
    s34_format()
    s35_lock()
    s36_onboarding_new()
    s37_onboarding_existing()
    s38_salary_zero_wage()
    s39_salary_basic()
    s40_format_won()
    s41_csv_parse()
    s42_csv_invalid()
    s43_csv_skip()
    s44_csv_overwrite()
    s45_notification_dedupe()
    s46_meal_record()
    s47_crash_log()
    s48_updater_compare()
    s49_discord_empty()
    s50_goals()
    s51_accessibility_keys()
    s52a_holiday_hotpath()
    s52b_planned_leave()
    s52c_recurring_leave()
    s52d_effective()
    s52e_full_day()
    s52_csv_overtime()

    print()
    print("=" * 60)
    print(f"PASS: {len(PASS)}  FAIL: {len(FAIL)}  WARN: {len(WARN)}")
    if FAIL:
        print()
        print("FAILED:")
        for label, err in FAIL:
            print(f"  - {label}: {err}")
    if WARN:
        print()
        print("WARNINGS:")
        for w in WARN:
            print(f"  - {w}")
    return 0 if not FAIL else 1


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