#!/usr/bin/env python3
import json
import os
import sqlite3
import socket
import traceback
import re
import csv
import requests
from datetime import datetime, timedelta, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, 'matching.db')
INDEX_HTML = os.path.join(BASE_DIR, 'index.html')
DETAIL_HTML = os.path.join(BASE_DIR, 'detail-view.html')
DETAIL_PROJECT_HTML = os.path.join(BASE_DIR, 'detail-view-project.html')
MYSQL_PREVIEW_HTML = os.path.join(BASE_DIR, 'mysql-preview.html')
SALARY_AVG_CSV_PATH = os.path.join(os.path.dirname(BASE_DIR), 'erp_salary_avg_by_title_year_2026-05-27.csv')

# MySQL direct source (user requested)
MYSQL_HOST = os.environ.get('MYSQL_HOST', '172.16.42.111')
MYSQL_USER = os.environ.get('MYSQL_USER', 'root')
MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'hanmacerp!')
MYSQL_DB = os.environ.get('MYSQL_DB', 'jangheon_manhour')
MYSQL_PORT = int(os.environ.get('MYSQL_PORT', '3306'))

ERP_BASE_URL = os.environ.get('ERP_BASE_URL', 'http://erp.jangheon.co.kr/projt_mng')
ERP_LOGIN_ID = os.environ.get('ERP_LOGIN_ID', 'g25001')
ERP_LOGIN_PW = os.environ.get('ERP_LOGIN_PW', '00000')
INTRANET_BASE_URL = os.environ.get('INTRANET_BASE_URL', 'http://erp.jangheon.co.kr/intranet/')
INTRANET_LOGIN_ID = os.environ.get('INTRANET_LOGIN_ID', 'G25001')
INTRANET_LOGIN_PW = os.environ.get('INTRANET_LOGIN_PW', '00000')



def init_db(conn):
    conn.executescript(
        '''
        CREATE TABLE IF NOT EXISTS member (
            MemberNo TEXT PRIMARY KEY,
            korName TEXT,
            rankCode TEXT DEFAULT '',
            rankName TEXT DEFAULT '',
            groupCode TEXT DEFAULT '',
            teamName TEXT DEFAULT '',
            retireFlag TEXT DEFAULT '',
            isRetired INTEGER DEFAULT 0
        );

        CREATE TABLE IF NOT EXISTS dallyproject (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            MemberNo TEXT,
            EntryTime TEXT,
            EntryPCode TEXT,
            EntryJobCode TEXT,
            EntryJob TEXT,
            LeaveTime TEXT,
            LeavePCode TEXT,
            LeaveJobCode TEXT,
            LeaveJob TEXT,
            OverTime TEXT,
            ConnectIP TEXT,
            OverWorkIP TEXT,
            EndWorkIP TEXT,
            SortKey TEXT,
            Note TEXT,
            modify TEXT,
            ConfirmDate TEXT,
            WorkDate TEXT,
            TotalHours REAL DEFAULT 0,
            RegularHours REAL DEFAULT 0,
            OvertimeHours REAL DEFAULT 0,
            BusinessTripHours REAL DEFAULT 0
        );

        CREATE TABLE IF NOT EXISTS worker_tardy (
            MemberNo TEXT,
            s_date TEXT,
            e_date TEXT,
            tardy_h INTEGER,
            tardy_m INTEGER
        );

        CREATE TABLE IF NOT EXISTS project_alias (
            projectCode TEXT PRIMARY KEY,
            shortName TEXT
        );

        CREATE INDEX IF NOT EXISTS idx_dally_memberno ON dallyproject(MemberNo);
        CREATE INDEX IF NOT EXISTS idx_tardy_member ON worker_tardy(MemberNo);
        '''
    )
    # Backward-compatible migration for existing DB files.
    cols = {row[1] for row in conn.execute("PRAGMA table_info(dallyproject)").fetchall()}
    mcols = {row[1] for row in conn.execute("PRAGMA table_info(member)").fetchall()}
    if 'rankCode' not in mcols:
        conn.execute("ALTER TABLE member ADD COLUMN rankCode TEXT DEFAULT ''")
    if 'rankName' not in mcols:
        conn.execute("ALTER TABLE member ADD COLUMN rankName TEXT DEFAULT ''")
    if 'groupCode' not in mcols:
        conn.execute("ALTER TABLE member ADD COLUMN groupCode TEXT DEFAULT ''")
    if 'teamName' not in mcols:
        conn.execute("ALTER TABLE member ADD COLUMN teamName TEXT DEFAULT ''")
    if 'retireFlag' not in mcols:
        conn.execute("ALTER TABLE member ADD COLUMN retireFlag TEXT DEFAULT ''")
    if 'isRetired' not in mcols:
        conn.execute("ALTER TABLE member ADD COLUMN isRetired INTEGER DEFAULT 0")
    if 'WorkDate' not in cols:
        conn.execute("ALTER TABLE dallyproject ADD COLUMN WorkDate TEXT")
    if 'TotalHours' not in cols:
        conn.execute("ALTER TABLE dallyproject ADD COLUMN TotalHours REAL DEFAULT 0")
    if 'RegularHours' not in cols:
        conn.execute("ALTER TABLE dallyproject ADD COLUMN RegularHours REAL DEFAULT 0")
    if 'OvertimeHours' not in cols:
        conn.execute("ALTER TABLE dallyproject ADD COLUMN OvertimeHours REAL DEFAULT 0")
    if 'BusinessTripHours' not in cols:
        conn.execute("ALTER TABLE dallyproject ADD COLUMN BusinessTripHours REAL DEFAULT 0")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_dally_workdate ON dallyproject(WorkDate)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_project_alias_code ON project_alias(projectCode)")
    conn.commit()


def normalize_member_no(v):
    return _as_text(v).strip().upper()


def _norm_key(k):
    return re.sub(r'[^a-z0-9]+', '', str(k or '').lower())


def _as_text(v):
    if v is None:
        return ''
    if isinstance(v, bytes):
        for enc in ('utf-8', 'cp949', 'euc-kr', 'latin1'):
            try:
                return v.decode(enc, errors='ignore')
            except Exception:
                continue
        return ''
    return str(v)


def _find_col_key(cols, aliases):
    if not cols:
        return ''
    alias_set = {_norm_key(a) for a in aliases}
    for k in cols.keys():
        if _norm_key(k) in alias_set:
            return k
    return ''


def _norm_code_token(v):
    s = _as_text(v).strip()
    if not s:
        return ''
    if re.fullmatch(r'\d+', s):
        return str(int(s))
    return s.upper()


def insert_member_rows(conn, rows):
    conn.execute('DELETE FROM member')
    conn.executemany(
        'INSERT OR REPLACE INTO member (MemberNo, korName, rankCode, rankName, groupCode, teamName, retireFlag, isRetired) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
        rows,
    )


def parse_retired_flag(raw_value, col_key=''):
    s = _as_text(raw_value).strip().lower()
    if not s:
        return 0
    col_key = _norm_key(col_key)
    # LeaveDate 계열: 유효 날짜가 오늘 이전/같으면 퇴사로 간주
    if col_key in ('leavedate', 'retiredate', 'quitdate', 'resigndate'):
        if s in ('0000-00-00', '0000-00-00 00:00:00'):
            return 0
        m = re.match(r'^(\d{4})-(\d{2})-(\d{2})', s)
        if m:
            try:
                d = datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).date()
                return 1 if d <= datetime.now().date() else 0
            except Exception:
                return 0
        return 0
    # useYn/workYn 계열은 N/0 이 퇴사 의미인 경우가 많음
    if col_key in ('useyn', 'workyn', 'isuse', 'iswork'):
        if s in ('n', '0', 'false', 'inactive'):
            return 1
        return 0
    if s in ('재직', '근무', '정상', 'active'):
        return 0
    retired_tokens = ('y', '1', 'true', 'retire', 'resign', '퇴사', '퇴직', 'inactive', 'leave', 'quit')
    return 1 if any(tok in s for tok in retired_tokens) else 0


def update_member_ranks(conn, rank_map):
    if not rank_map:
        return 0
    rows = [(rank_map[k], k) for k in rank_map.keys()]
    conn.executemany('UPDATE member SET rankName = ? WHERE MemberNo = ?', rows)
    conn.commit()
    return len(rows)


def update_member_ranks_by_rankcode(conn, code_to_name):
    if not code_to_name:
        return 0
    cur = conn.execute('SELECT MemberNo, IFNULL(rankCode, "") FROM member')
    updates = []
    for mno, rc in cur.fetchall():
        key = _as_text(rc).strip()
        if not key:
            continue
        rn = _as_text(code_to_name.get(key) or code_to_name.get(_norm_code_token(key))).strip()
        if rn:
            updates.append((rn, mno))
    if updates:
        conn.executemany('UPDATE member SET rankName = ? WHERE MemberNo = ?', updates)
        conn.commit()
    return len(updates)


def insert_daily_rows(conn, rows):
    conn.executemany(
        '''
        INSERT INTO dallyproject (
          MemberNo, EntryTime, EntryPCode, EntryJobCode, EntryJob,
          LeaveTime, LeavePCode, LeaveJobCode, LeaveJob, OverTime,
          ConnectIP, OverWorkIP, EndWorkIP, SortKey, Note, modify, ConfirmDate,
          WorkDate, TotalHours, RegularHours, OvertimeHours, BusinessTripHours
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ''',
        rows,
    )


def parse_dt(value):
    s = _as_text(value).strip()
    if not s:
        return None
    fmts = ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M')
    for f in fmts:
        try:
            return datetime.strptime(s, f)
        except Exception:
            pass
    return None


def parse_d(value):
    s = _as_text(value).strip()
    if not s:
        return None
    for f in ('%Y-%m-%d', '%Y/%m/%d'):
        try:
            return datetime.strptime(s, f).date()
        except Exception:
            pass
    return None


def insert_worker_tardy(conn, rows):
    conn.execute('DELETE FROM worker_tardy')
    if rows:
        conn.executemany(
            'INSERT INTO worker_tardy (MemberNo, s_date, e_date, tardy_h, tardy_m) VALUES (?, ?, ?, ?, ?)',
            rows
        )


def build_tardy_map(conn):
    cur = conn.execute('SELECT MemberNo, s_date, e_date, tardy_h, tardy_m FROM worker_tardy')
    out = {}
    for mno, sd, ed, th, tm in cur.fetchall():
        sdd = parse_d(sd)
        edd = parse_d(ed)
        if not sdd or not edd:
            continue
        out.setdefault(mno, []).append((sdd, edd, int(th or 9), int(tm or 0)))
    for k in out:
        out[k].sort(key=lambda x: (x[0], x[1]))
    return out


def tardy_start_for_day(tardy_map, member_no, day):
    rules = tardy_map.get(member_no, [])
    for sd, ed, th, tm in rules:
        if sd <= day <= ed:
            return th, tm
    return 9, 0


def overlap_hours(a_start, a_end, b_start, b_end):
    s = max(a_start, b_start)
    e = min(a_end, b_end)
    if e <= s:
        return 0.0
    return (e - s).total_seconds() / 3600.0


def _is_business_trip_text(*texts):
    for t in texts:
        s = _as_text(t).strip()
        if '출장' in s:
            return True
    return False


def compute_hours(
    entry_time, leave_time, overtime_time, member_no, tardy_map,
    entry_job='', leave_job='', entry_job_code='', leave_job_code=''
):
    ent = parse_dt(entry_time)
    lea = parse_dt(leave_time)
    is_trip = _is_business_trip_text(entry_job, leave_job, entry_job_code, leave_job_code)
    if not ent:
        return '', 0.0, 0.0, 0.0, 0.0
    # 출장: LeaveTime 없어도 8시간 인정(별도 컬럼 분리)
    if not lea and is_trip:
        day = ent.date()
        return day.isoformat(), 8.0, 0.0, 0.0, 8.0
    if not lea:
        return '', 0.0, 0.0, 0.0, 0.0
    if lea < ent:
        lea = lea + timedelta(days=1)
    base_hours = max(0.0, (lea - ent).total_seconds() / 3600.0)
    ot_raw = (overtime_time or '').strip()
    extra_hours = 0.0
    day = ent.date()
    th, tm = tardy_start_for_day(tardy_map, member_no, day)
    regular_start = datetime.combine(day, time(th, tm))
    regular_end = regular_start + timedelta(hours=8)

    # If OverTime is empty/zero datetime, treat as no overtime.
    if ot_raw and not ot_raw.startswith('0000-00-00'):
        ot_dt = parse_dt(ot_raw)
        if ot_dt:
            # Case 1: explicit overtime end time
            if ot_dt > lea:
                extra_hours = max(0.0, (ot_dt - lea).total_seconds() / 3600.0)
            # Case 2: legacy/dirty rows where OverTime is equal to or earlier than leave
            # In this case infer overtime from actual leave against regular end.
            elif ot_dt <= lea:
                extra_hours = max(0.0, (lea - regular_end).total_seconds() / 3600.0)

    # Regular work policy:
    # - Weekday only
    # - Deduct lunch overlap (12:00~13:00)
    # - Convert to whole hours only (no 0.5 etc., floor)
    # - Cap at 8 hours/day
    lunch_start = datetime.combine(day, time(12, 0))
    lunch_end = datetime.combine(day, time(13, 0))
    lunch_overlap = overlap_hours(ent, lea, lunch_start, lunch_end)
    regular_raw = max(0.0, base_hours - lunch_overlap)
    is_weekend = day.weekday() >= 5  # 5: Sat, 6: Sun
    regular = 0.0 if is_weekend else float(min(8, int(regular_raw)))
    # Overtime is counted only from OverTime field difference (OverTime - LeaveTime).
    # Overtime policy by date (approved-unit mode):
    # - Weekday: if >= 2h, 인정 3h (otherwise 0h)
    # - Weekend/holiday: use worked hours, if >= 3h then cap at 5h (otherwise 0h)
    if is_weekend:
        weekend_hours = max(0.0, regular_raw)
        overtime = 0.0 if weekend_hours < 3.0 else min(float(int(weekend_hours)), 5.0)
    else:
        overtime = 3.0 if extra_hours >= 2.0 else 0.0
    business_trip = 0.0
    # 출장 행은 근무시간(regular)을 출장시간으로 분리 표기
    # 총시간(total)은 유지되도록 regular -> business_trip 이동.
    if is_trip:
        business_trip = regular
        regular = 0.0
    total = regular + overtime + business_trip
    return day.isoformat(), round(total, 2), round(regular, 2), round(overtime, 2), round(business_trip, 2)


def backfill_daily_hours_if_needed(conn):
    # Recalculate all workable rows to keep rules consistent across code updates.
    need = conn.execute(
        '''
        SELECT COUNT(*)
        FROM dallyproject
        WHERE IFNULL(EntryTime, '') <> ''
          AND IFNULL(LeaveTime, '') <> ''
        '''
    ).fetchone()[0]
    if need == 0:
        return 0

    tardy_map = build_tardy_map(conn)
    cur = conn.execute('SELECT id, MemberNo, EntryTime, LeaveTime, OverTime FROM dallyproject')
    updates = []
    cur = conn.execute('SELECT id, MemberNo, EntryTime, LeaveTime, OverTime, EntryJob, LeaveJob, EntryJobCode, LeaveJobCode FROM dallyproject')
    updates = []
    for rid, mno, ent, lea, ot, entry_job, leave_job, entry_job_code, leave_job_code in cur.fetchall():
        work_date, total_h, regular_h, overtime_h, business_trip_h = compute_hours(
            ent, lea, ot, mno or '', tardy_map, entry_job or '', leave_job or '', entry_job_code or '', leave_job_code or ''
        )
        updates.append((work_date, total_h, regular_h, overtime_h, business_trip_h, rid))
        if len(updates) >= 5000:
            conn.executemany(
                'UPDATE dallyproject SET WorkDate=?, TotalHours=?, RegularHours=?, OvertimeHours=?, BusinessTripHours=? WHERE id=?',
                updates
            )
            updates = []
    if updates:
        conn.executemany(
            'UPDATE dallyproject SET WorkDate=?, TotalHours=?, RegularHours=?, OvertimeHours=?, BusinessTripHours=? WHERE id=?',
            updates
        )
    conn.commit()
    return need


def load_project_alias_from_erp():
    login_url = ERP_BASE_URL.rstrip('/') + '/sys/controller/common/main_controller.php'
    s = requests.Session()
    login_payload = {
        'ActionMode': 'LoginCheck',
        'memberID': ERP_LOGIN_ID,
        'LoginID': ERP_LOGIN_ID,
        'passwd': ERP_LOGIN_PW,
        'CheckSave': '',
        'login': '1',
    }
    r = s.post(login_url, data=login_payload, timeout=20)
    if r.text.strip().lower() != 'success':
        raise RuntimeError(f'ERP login failed: {r.text[:120]}')

    pages = ['sales', 'design', 'const', 'make', 'research']
    out = {}
    for page in pages:
        payload = {
            'ActionMode': 'getMain',
            'SubAction': 'getProjectList',
            'page': page,
            'searchText': '',
        }
        rr = s.post(login_url + '?', data=payload, timeout=20)
        body = rr.text.strip()
        if not body:
            continue
        data = json.loads(body)
        for item in data.get('list_data', []) or []:
            code = _as_text(item.get('ProjectCode')).strip().upper()
            nick = _as_text(item.get('ProjectNickname')).strip()
            if code and nick:
                out[code] = nick
    return sorted(out.items())


def _strip_tags(s):
    s = re.sub(r'<[^>]+>', '', s or '')
    s = s.replace('&nbsp;', ' ').replace('&#160;', ' ')
    return s.strip()


def load_member_ranks_from_intranet():
    login_url = INTRANET_BASE_URL.rstrip('/') + '/sys/popup/login_ok.php'
    search_url = INTRANET_BASE_URL.rstrip('/') + '/sys/controller/codeSearch_controller.php?ActionMode=searchMember'
    s = requests.Session()
    # session init
    s.get(INTRANET_BASE_URL, timeout=20)
    payload = {
        'user_id': INTRANET_LOGIN_ID,
        'user_pw': INTRANET_LOGIN_PW,
        'user_ip': '',
        'user_contact_area': 'OUT',
        'checksaveid': '',
        'is_ajax': 1,
    }
    r = s.post(login_url, data=payload, timeout=20)
    if str(r.text).strip() != '1':
        raise RuntimeError(f'Intranet login failed: {r.text[:120]}')

    rr = s.post(
        search_url,
        data={'ajax_insertStr': '', 'ajax_returnId01': 'a', 'ajax_returnId02': 'b'},
        timeout=20
    )
    html = rr.content.decode('utf-8', errors='ignore')
    rank_map = {}
    for tr in re.findall(r'<tr[^>]*>(.*?)</tr>', html, re.I | re.S):
        tds = re.findall(r'<td[^>]*>(.*?)</td>', tr, re.I | re.S)
        if len(tds) < 5:
            continue
        member_no = normalize_member_no(_strip_tags(tds[3]))
        rank_name = _strip_tags(tds[4])
        if member_no and rank_name:
            rank_map[member_no] = rank_name
    return rank_map


def replace_project_alias(conn, rows):
    conn.execute('DELETE FROM project_alias')
    if rows:
        conn.executemany(
            'INSERT OR REPLACE INTO project_alias (projectCode, shortName) VALUES (?, ?)',
            rows
        )
    conn.commit()


def get_project_alias_map(conn):
    cur = conn.execute('SELECT projectCode, shortName FROM project_alias')
    return {code: short for code, short in cur.fetchall() if code}


def _mysql_connect():
    try:
        import pymysql
    except Exception as e:
        raise RuntimeError('pymysql 모듈이 필요합니다. `pip install pymysql` 실행 후 다시 시도하세요.') from e
    last_err = None
    for cs in ('utf8mb4', 'utf8'):
        try:
            return pymysql.connect(
                host=MYSQL_HOST,
                user=MYSQL_USER,
                password=MYSQL_PASSWORD,
                database=MYSQL_DB,
                port=MYSQL_PORT,
                charset=cs,
                cursorclass=pymysql.cursors.DictCursor,
                autocommit=True,
            )
        except Exception as e:
            last_err = e
    raise last_err


def fetch_mysql_tables():
    mysql_conn = _mysql_connect()
    try:
        with mysql_conn.cursor() as cur:
            cur.execute("SHOW TABLES")
            rows = cur.fetchall() or []
        out = []
        for row in rows:
            if isinstance(row, dict):
                out.extend([_as_text(v).strip() for v in row.values() if _as_text(v).strip()])
            else:
                out.extend([_as_text(v).strip() for v in row if _as_text(v).strip()])
        return sorted(set(out))
    finally:
        try:
            mysql_conn.close()
        except Exception:
            pass


def fetch_mysql_table_preview(table_name, limit=100):
    tn = _as_text(table_name).strip()
    if not re.fullmatch(r'[A-Za-z0-9_]+', tn):
        raise ValueError('table 파라미터가 올바르지 않습니다.')
    row_limit = max(1, min(int(limit or 100), 500))
    mysql_conn = _mysql_connect()
    try:
        with mysql_conn.cursor() as cur:
            cur.execute(f"SELECT * FROM `{tn}` LIMIT {row_limit}")
            rows = cur.fetchall() or []
        return rows
    finally:
        try:
            mysql_conn.close()
        except Exception:
            pass


def _quote_ident(name):
    return '`' + str(name).replace('`', '``') + '`'


def discover_mysql_tables(mysql_conn):
    with mysql_conn.cursor() as cur:
        cur.execute(
            '''
            SELECT TABLE_NAME, COLUMN_NAME
            FROM INFORMATION_SCHEMA.COLUMNS
            WHERE TABLE_SCHEMA = %s
            ''',
            (MYSQL_DB,),
        )
        rows = cur.fetchall()

    table_cols = {}
    for r in rows:
        t = r['TABLE_NAME']
        c = r['COLUMN_NAME']
        table_cols.setdefault(t, {})[c.lower()] = c

    member_table = None
    daily_tables = []

    for t, cols in table_cols.items():
        if 'memberno' in cols and 'korname' in cols and member_table is None:
            member_table = t

    for t, cols in table_cols.items():
        if 'memberno' in cols and 'entrytime' in cols and 'entrypcode' in cols:
            daily_tables.append(t)

    # avoid loading member table as daily table
    if member_table in daily_tables:
        daily_tables = [t for t in daily_tables if t != member_table]

    tardy_tables = []
    for t, cols in table_cols.items():
        if 'memberno' in cols and 's_date' in cols and 'e_date' in cols and 'tardy_h' in cols and 'tardy_m' in cols:
            tardy_tables.append(t)

    return member_table, sorted(daily_tables), sorted(tardy_tables), table_cols


def load_rank_map_from_systemconfig(mysql_conn, table_cols):
    # rankCode는 systemconfig의 PositionCode 기준으로 매핑
    mapped = load_syskey_code_name_map(mysql_conn, table_cols, 'PositionCode')
    if mapped:
        return mapped
    # fallback
    return load_syskey_code_name_map(mysql_conn, table_cols, 'PositionRank')


def load_syskey_code_name_map(mysql_conn, table_cols, target_syskey):
    target = str(target_syskey or '').strip().lower()
    if not target:
        return {}
    candidates = []
    for t, cols in table_cols.items():
        keys = {k.lower(): v for k, v in cols.items()}
        syskey_k = _find_col_key(keys, ('syskey', 'sys_key'))
        code_k = _find_col_key(keys, ('code',))
        name_k = _find_col_key(keys, ('name', 'codename', 'rankname', 'value'))
        if syskey_k and code_k and name_k:
            score = 10 + (40 if 'systemconfig' in t.lower() else 0)
            candidates.append((score, t, keys[syskey_k], keys[code_k], keys[name_k]))
    if not candidates:
        return {}
    candidates.sort(reverse=True)
    _, table_name, syskey_col, code_col, name_col = candidates[0]
    q = f'''
        SELECT {_quote_ident(code_col)} AS code,
               {_quote_ident(name_col)} AS name
        FROM {_quote_ident(table_name)}
        WHERE LOWER(TRIM({_quote_ident(syskey_col)})) = %s
    '''
    out = {}
    with mysql_conn.cursor() as cur:
        cur.execute(q, (target,))
        for r in cur.fetchall():
            code = _as_text(r.get('code')).strip()
            name = _as_text(r.get('name')).strip()
            if code:
                out[code] = name
                nk = _norm_code_token(code)
                if nk and nk not in out:
                    out[nk] = name
    return out


def load_from_mysql_into_sqlite(sqlite_conn):
    mysql_conn = _mysql_connect()
    try:
        member_table, daily_tables, tardy_tables, table_cols = discover_mysql_tables(mysql_conn)
        if not member_table:
            raise RuntimeError('MySQL에서 member 테이블(컬럼: MemberNo, korName)을 찾지 못했습니다.')
        if not daily_tables:
            raise RuntimeError('MySQL에서 일지 테이블(컬럼: MemberNo, EntryTime, EntryPCode)을 찾지 못했습니다.')

        # load member
        mcols = table_cols[member_table]
        m_memberno = _quote_ident(mcols['memberno'])
        m_korname = _quote_ident(mcols['korname'])
        rank_col_key = _find_col_key(mcols, ('rankcode', 'rank_code', 'rkcode', 'jobgrade', 'gradecode'))
        m_rankcode = _quote_ident(mcols[rank_col_key]) if rank_col_key else "''"
        group_col_key = _find_col_key(mcols, ('groupcode', 'group_code', 'teamcode', 'team_code', 'groupid', 'group_id'))
        m_groupcode = _quote_ident(mcols[group_col_key]) if group_col_key else "''"
        workpos_col_key = _find_col_key(mcols, ('workposition', 'work_position', 'workstate', 'work_state', 'statecode', 'state_code'))
        m_workpos = _quote_ident(mcols[workpos_col_key]) if workpos_col_key else "''"
        retire_col_key = _find_col_key(
            mcols,
            (
                'isretired', 'retired', 'retireyn', 'retire_yn', 'quityn', 'quit_yn', 'resignyn', 'resign_yn',
                'outyn', 'out_yn', 'useyn', 'use_yn', 'workyn', 'work_yn', 'status', 'state', 'empstate',
                'emp_state', 'retireflag', 'retire_flag', 'quitdate', 'leave_date', 'leavedate', 'retiredate'
            ),
        )
        m_retire = _quote_ident(mcols[retire_col_key]) if retire_col_key else "''"
        mt = _quote_ident(member_table)
        workpos_map = load_syskey_code_name_map(mysql_conn, table_cols, 'WorkPositionCode')
        group_map = load_syskey_code_name_map(mysql_conn, table_cols, 'GroupCode')
        group_map_del = load_syskey_code_name_map(mysql_conn, table_cols, 'GroupCode_del')
        if group_map_del:
            for k, v in group_map_del.items():
                if k not in group_map:
                    group_map[k] = v
        member_rows = []
        with mysql_conn.cursor() as cur:
            cur.execute(
                f"SELECT {m_memberno} AS MemberNo, {m_korname} AS korName, {m_rankcode} AS rankCode, {m_groupcode} AS groupCode, {m_workpos} AS workPosition, {m_retire} AS retireFlag FROM {mt}"
            )
            for r in cur.fetchall():
                mno = normalize_member_no(r.get('MemberNo'))
                if not mno:
                    continue
                retire_raw = _as_text(r.get('retireFlag')).strip()
                workpos_code = _as_text(r.get('workPosition')).strip()
                workpos_name = _as_text(workpos_map.get(workpos_code) or workpos_map.get(_norm_code_token(workpos_code))).strip()
                group_code = _as_text(r.get('groupCode')).strip()
                team_name = _as_text(group_map.get(group_code) or group_map.get(_norm_code_token(group_code))).strip()
                is_ret = 0
                is_ret = max(is_ret, parse_retired_flag(retire_raw, retire_col_key))
                if workpos_code == '9' or ('퇴사' in workpos_name):
                    is_ret = 1
                # retireFlag에 원본 퇴사값이 없으면 WorkPosition 기반 상태명을 사용
                retire_flag = retire_raw or workpos_name or workpos_code
                member_rows.append(
                    (
                        mno,
                        _as_text(r.get('korName')).strip(),
                        _as_text(r.get('rankCode')).strip(),
                        '',
                        group_code,
                        team_name,
                        retire_flag,
                        is_ret,
                    )
                )

        insert_member_rows(sqlite_conn, member_rows)
        rank_map = load_rank_map_from_systemconfig(mysql_conn, table_cols)
        rank_loaded = update_member_ranks_by_rankcode(sqlite_conn, rank_map)

        # load tardy rules
        tardy_rows = []
        for t in tardy_tables:
            cols = table_cols[t]
            q = f'''
            SELECT {_quote_ident(cols['memberno'])} AS memberno,
                   {_quote_ident(cols['s_date'])} AS s_date,
                   {_quote_ident(cols['e_date'])} AS e_date,
                   {_quote_ident(cols['tardy_h'])} AS tardy_h,
                   {_quote_ident(cols['tardy_m'])} AS tardy_m
            FROM {_quote_ident(t)}
            '''
            with mysql_conn.cursor() as cur:
                cur.execute(q)
                for r in cur.fetchall():
                    mno = normalize_member_no(r.get('memberno'))
                    sd = (r.get('s_date') or '').__str__()[:10]
                    ed = (r.get('e_date') or '').__str__()[:10]
                    try:
                        th = int(float(r.get('tardy_h') or 9))
                        tm = int(float(r.get('tardy_m') or 0))
                    except Exception:
                        th, tm = 9, 0
                    if mno and sd and ed:
                        tardy_rows.append((mno, sd, ed, th, tm))
        insert_worker_tardy(sqlite_conn, tardy_rows)
        tardy_map = build_tardy_map(sqlite_conn)

        # load daily tables with dedupe
        sqlite_conn.execute('DELETE FROM dallyproject')
        seen_rows = set()
        loaded_files = []
        total_rows = 0
        total_dups = 0

        aliases = [
            'MemberNo', 'EntryTime', 'EntryPCode', 'EntryJobCode', 'EntryJob',
            'LeaveTime', 'LeavePCode', 'LeaveJobCode', 'LeaveJob', 'OverTime',
            'ConnectIP', 'OverWorkIP', 'EndWorkIP', 'SortKey', 'Note', 'modify', 'ConfirmDate'
        ]

        for t in daily_tables:
            cols = table_cols[t]
            select_parts = []
            for a in aliases:
                key = a.lower()
                if key in cols:
                    select_parts.append(f"COALESCE({_quote_ident(cols[key])}, '') AS {a}")
                else:
                    select_parts.append(f"'' AS {a}")

            query = f"SELECT {', '.join(select_parts)} FROM {_quote_ident(t)}"
            inserted = 0
            dups = 0
            batch = []

            with mysql_conn.cursor() as cur:
                cur.execute(query)
                while True:
                    chunk = cur.fetchmany(5000)
                    if not chunk:
                        break
                    for r in chunk:
                        row = (
                            normalize_member_no(r.get('MemberNo')),
                            _as_text(r.get('EntryTime')).strip(),
                            _as_text(r.get('EntryPCode')).strip(),
                            _as_text(r.get('EntryJobCode')).strip(),
                            _as_text(r.get('EntryJob')).strip(),
                            _as_text(r.get('LeaveTime')).strip(),
                            _as_text(r.get('LeavePCode')).strip(),
                            _as_text(r.get('LeaveJobCode')).strip(),
                            _as_text(r.get('LeaveJob')).strip(),
                            _as_text(r.get('OverTime')).strip(),
                            _as_text(r.get('ConnectIP')).strip(),
                            _as_text(r.get('OverWorkIP')).strip(),
                            _as_text(r.get('EndWorkIP')).strip(),
                            _as_text(r.get('SortKey')).strip(),
                            _as_text(r.get('Note')).strip(),
                            _as_text(r.get('modify')).strip(),
                            _as_text(r.get('ConfirmDate')).strip(),
                        )
                        if row in seen_rows:
                            dups += 1
                            continue
                        seen_rows.add(row)
                        work_date, total_h, regular_h, overtime_h, business_trip_h = compute_hours(
                            row[1], row[5], row[9], row[0], tardy_map, row[4], row[8], row[3], row[7]
                        )
                        batch.append(row + (work_date, total_h, regular_h, overtime_h, business_trip_h))

                    if batch:
                        insert_daily_rows(sqlite_conn, batch)
                        inserted += len(batch)
                        batch = []

            loaded_files.append({'file': t, 'rows': inserted, 'duplicatesSkipped': dups})
            total_rows += inserted
            total_dups += dups

        sqlite_conn.commit()
        return {
            'source': 'mysql',
            'member_loaded': len(member_rows),
            'member_rank_source': 'mysql_systemconfig' if rank_map else 'mysql_systemconfig_not_found',
            'member_rank_loaded': rank_loaded,
            'dally_loaded': total_rows,
            'duplicates_skipped': total_dups,
            'daily_files': loaded_files,
        }
    finally:
        try:
            mysql_conn.close()
        except Exception:
            pass


def get_stats(conn):
    total = conn.execute('SELECT COUNT(*) FROM dallyproject').fetchone()[0]
    matched = conn.execute(
        '''
        SELECT COUNT(*)
        FROM dallyproject d
        JOIN member m ON m.MemberNo = d.MemberNo
        WHERE IFNULL(m.korName, '') <> ''
        '''
    ).fetchone()[0]
    return {'total': total, 'matched': matched, 'unmatched': total - matched}


def _duration_sql(alias='d'):
    return f'''
    CASE
      WHEN {alias}.EntryTime IS NULL OR {alias}.LeaveTime IS NULL OR {alias}.EntryTime = '' OR {alias}.LeaveTime = '' THEN 0
      ELSE (
        (
          strftime('%s', CASE WHEN length({alias}.LeaveTime) = 16 THEN {alias}.LeaveTime || ':00' ELSE {alias}.LeaveTime END) -
          strftime('%s', CASE WHEN length({alias}.EntryTime) = 16 THEN {alias}.EntryTime || ':00' ELSE {alias}.EntryTime END) +
          CASE
            WHEN strftime('%s', CASE WHEN length({alias}.LeaveTime) = 16 THEN {alias}.LeaveTime || ':00' ELSE {alias}.LeaveTime END) <
                 strftime('%s', CASE WHEN length({alias}.EntryTime) = 16 THEN {alias}.EntryTime || ':00' ELSE {alias}.EntryTime END)
            THEN 86400
            ELSE 0
          END
        ) / 3600.0
      )
    END
    '''


def get_people_summary(conn, start_date, end_date):
    q = f'''
    SELECT
      m.MemberNo,
      IFNULL(m.korName, '') AS korName,
      IFNULL(m.rankName, '') AS rankName,
      IFNULL(m.teamName, '') AS teamName,
      IFNULL(m.retireFlag, '') AS retireFlag,
      IFNULL(m.isRetired, 0) AS isRetired,
      ROUND(IFNULL(SUM(CASE WHEN date(substr(d.EntryTime,1,10)) BETWEEN date(?) AND date(?) THEN d.TotalHours ELSE 0 END), 0), 2) AS totalHours,
      IFNULL(SUM(CASE WHEN date(substr(d.EntryTime,1,10)) BETWEEN date(?) AND date(?) THEN 1 ELSE 0 END), 0) AS totalRows
    FROM member m
    LEFT JOIN dallyproject d ON d.MemberNo = m.MemberNo
    GROUP BY m.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, ''), IFNULL(m.retireFlag, ''), IFNULL(m.isRetired, 0)
    ORDER BY CASE WHEN IFNULL(m.korName,'')='' THEN 1 ELSE 0 END, korName ASC, m.MemberNo ASC
    '''
    cur = conn.execute(q, (start_date, end_date, start_date, end_date))
    cols = [d[0] for d in cur.description]
    return [dict(zip(cols, row)) for row in cur.fetchall()]


def get_project_summary(conn, start_date, end_date):
    q = f'''
    SELECT d.EntryPCode AS projectCode,
           ROUND(SUM(d.TotalHours), 2) AS totalHours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
           COUNT(*) AS totalRows,
           COUNT(DISTINCT d.MemberNo) AS peopleCount
    FROM dallyproject d
    WHERE date(substr(d.EntryTime, 1, 10)) >= date(?)
      AND date(substr(d.EntryTime, 1, 10)) <= date(?)
      AND IFNULL(d.EntryPCode, '') <> ''
    GROUP BY d.EntryPCode
    HAVING totalHours > 0
    ORDER BY totalHours DESC, totalRows DESC
    '''
    cur = conn.execute(q, (start_date, end_date))
    cols = [d[0] for d in cur.description]
    return [dict(zip(cols, row)) for row in cur.fetchall()]


def get_member_dashboard(conn, start_date, end_date, member_no):
    where = "date(substr(d.EntryTime,1,10)) >= date(?) AND date(substr(d.EntryTime,1,10)) <= date(?)"
    params = [start_date, end_date]
    if member_no:
        where += " AND d.MemberNo = ?"
        params.append(member_no)

    summary_q = f'''
    SELECT ROUND(SUM(d.TotalHours), 2) AS totalHours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
           ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
           COUNT(*) AS totalRows,
           COUNT(DISTINCT CASE WHEN d.TotalHours > 0 THEN d.MemberNo END) AS peopleCount,
           COUNT(DISTINCT CASE WHEN d.TotalHours > 0 AND IFNULL(d.EntryPCode,'') <> '' THEN d.EntryPCode END) AS projectCount
    FROM dallyproject d
    WHERE {where}
    '''
    total_hours, regular_hours, overtime_hours, business_trip_hours, total_rows, people_count, project_count = conn.execute(summary_q, params).fetchone()

    project_q = f'''
    SELECT d.EntryPCode AS projectCode,
           ROUND(SUM(d.TotalHours), 2) AS hours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
           ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
           COUNT(*) AS rowsCount
    FROM dallyproject d
    WHERE {where}
    GROUP BY d.EntryPCode
    HAVING hours > 0
    ORDER BY hours DESC
    '''
    cur = conn.execute(project_q, params)
    cols = [d[0] for d in cur.description]
    projects = [dict(zip(cols, row)) for row in cur.fetchall()]

    yearly_q = f'''
    SELECT substr(d.EntryTime, 1, 7) AS yearMonth,
           substr(d.EntryTime, 1, 4) AS yearCode,
           d.EntryPCode AS projectCode,
           CASE
             WHEN instr(d.EntryPCode, '-') = 0 THEN '기타'
             ELSE
               CASE
                 WHEN instr(substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1), '-') = 0 THEN '기타'
                 ELSE substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1, instr(substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1), '-') - 1)
               END
           END AS typeCode,
           ROUND(SUM(d.TotalHours), 2) AS hours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
           ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
           COUNT(*) AS rowsCount
    FROM dallyproject d
    WHERE {where}
    GROUP BY yearMonth, yearCode, projectCode, typeCode
    HAVING hours > 0
    ORDER BY yearMonth ASC, hours DESC
    '''
    cur2 = conn.execute(yearly_q, params)
    cols2 = [d[0] for d in cur2.description]
    project_yearly = [dict(zip(cols2, row)) for row in cur2.fetchall()]

    member_name = ''
    member_rank = ''
    member_team = ''
    if member_no:
        r = conn.execute(
            'SELECT IFNULL(korName, ""), IFNULL(rankName, ""), IFNULL(teamName, "") FROM member WHERE MemberNo = ?',
            (member_no,),
        ).fetchone()
        if r:
            member_name, member_rank, member_team = r[0], r[1], r[2]

    return {
        'start': start_date,
        'end': end_date,
        'memberNo': member_no,
        'korName': member_name,
        'rankName': member_rank,
        'teamName': member_team,
        'totalHours': total_hours or 0,
        'regularHours': regular_hours or 0,
        'overtimeHours': overtime_hours or 0,
        'businessTripHours': business_trip_hours or 0,
        'totalRows': total_rows or 0,
        'peopleCount': people_count or 0,
        'projectCount': project_count or 0,
        'projects': projects,
        'projectYearly': project_yearly,
    }


def get_project_dashboard(conn, start_date, end_date, project_code):
    where = "date(substr(d.EntryTime,1,10)) >= date(?) AND date(substr(d.EntryTime,1,10)) <= date(?)"
    params = [start_date, end_date]
    if project_code:
        where += " AND d.EntryPCode = ?"
        params.append(project_code)

    summary_q = f'''
    SELECT ROUND(SUM(d.TotalHours), 2) AS totalHours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
           ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
           COUNT(*) AS totalRows,
           COUNT(DISTINCT CASE WHEN d.TotalHours > 0 THEN d.MemberNo END) AS peopleCount,
           COUNT(DISTINCT CASE WHEN d.TotalHours > 0 AND IFNULL(d.EntryPCode,'') <> '' THEN d.EntryPCode END) AS projectCount
    FROM dallyproject d
    WHERE {where}
    '''
    total_hours, regular_hours, overtime_hours, business_trip_hours, total_rows, people_count, project_count = conn.execute(summary_q, params).fetchone()

    people_q = f'''
    SELECT d.MemberNo,
           IFNULL(m.korName, '') AS korName,
           IFNULL(m.rankName, '') AS rankName,
           IFNULL(m.teamName, '') AS teamName,
           ROUND(SUM(d.TotalHours), 2) AS hours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
           ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
           COUNT(*) AS rowsCount
    FROM dallyproject d
    LEFT JOIN member m ON m.MemberNo = d.MemberNo
    WHERE {where}
    GROUP BY d.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, '')
    HAVING hours > 0
    ORDER BY hours DESC
    '''
    cur = conn.execute(people_q, params)
    cols = [d[0] for d in cur.description]
    people = [dict(zip(cols, row)) for row in cur.fetchall()]

    people_year_q = f'''
    SELECT substr(d.EntryTime, 1, 4) AS yearCode,
           d.MemberNo,
           IFNULL(m.korName, '') AS korName,
           IFNULL(m.rankName, '') AS rankName,
           IFNULL(m.teamName, '') AS teamName,
           ROUND(SUM(d.TotalHours), 2) AS hours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
           ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
           COUNT(*) AS rowsCount
    FROM dallyproject d
    LEFT JOIN member m ON m.MemberNo = d.MemberNo
    WHERE {where}
    GROUP BY yearCode, d.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, '')
    HAVING hours > 0
    ORDER BY yearCode ASC, hours DESC
    '''
    cur2 = conn.execute(people_year_q, params)
    cols2 = [d[0] for d in cur2.description]
    people_yearly = [dict(zip(cols2, row)) for row in cur2.fetchall()]

    return {
        'start': start_date,
        'end': end_date,
        'projectCode': project_code,
        'totalHours': total_hours or 0,
        'regularHours': regular_hours or 0,
        'overtimeHours': overtime_hours or 0,
        'businessTripHours': business_trip_hours or 0,
        'totalRows': total_rows or 0,
        'peopleCount': people_count or 0,
        'projectCount': project_count or 0,
        'people': people,
        'peopleYearly': people_yearly,
    }


def get_project_monthly_detail(conn, start_date, end_date, project_code):
    where = "date(substr(d.EntryTime,1,10)) >= date(?) AND date(substr(d.EntryTime,1,10)) <= date(?)"
    params = [start_date, end_date]
    if project_code:
        where += " AND d.EntryPCode = ?"
        params.append(project_code)

    q = f'''
    SELECT substr(d.EntryTime, 1, 7) AS yearMonth,
           substr(d.EntryTime, 1, 4) AS yearCode,
           d.MemberNo,
           IFNULL(m.korName, '') AS korName,
           IFNULL(m.rankName, '') AS rankName,
           IFNULL(m.teamName, '') AS teamName,
           ROUND(SUM(d.TotalHours), 2) AS hours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours
    FROM dallyproject d
    LEFT JOIN member m ON m.MemberNo = d.MemberNo
    WHERE {where}
    GROUP BY yearMonth, yearCode, d.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, '')
    HAVING hours > 0
    ORDER BY yearMonth ASC, hours DESC
    '''
    cur = conn.execute(q, params)
    cols = [d[0] for d in cur.description]
    return {'rows': [dict(zip(cols, row)) for row in cur.fetchall()]}


def get_member_yearly_type_breakdown(conn, start_date, end_date, member_no):
    where = "date(substr(d.EntryTime,1,10)) >= date(?) AND date(substr(d.EntryTime,1,10)) <= date(?)"
    params = [start_date, end_date]
    if member_no:
        where += " AND d.MemberNo = ?"
        params.append(member_no)

    q = f'''
    SELECT substr(d.EntryPCode, 1, 2) AS yearCode,
           CASE
             WHEN instr(d.EntryPCode, '-') = 0 THEN '기타'
             ELSE
               CASE
                 WHEN instr(substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1), '-') = 0 THEN '기타'
                 ELSE substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1, instr(substr(d.EntryPCode, instr(d.EntryPCode, '-') + 1), '-') - 1)
               END
           END AS typeCode,
           ROUND(SUM(d.TotalHours), 2) AS hours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours,
           COUNT(*) AS rowsCount
    FROM dallyproject d
    WHERE {where}
    GROUP BY yearCode, typeCode
    HAVING hours > 0
    ORDER BY yearCode ASC, hours DESC
    '''
    cur = conn.execute(q, params)
    rows = cur.fetchall()
    by_year = {}
    for year_code, type_code, hours, regular_hours, overtime_hours, rows_count in rows:
        y = year_code or '기타'
        by_year.setdefault(y, {'yearCode': y, 'totalHours': 0, 'types': []})
        by_year[y]['totalHours'] += float(hours or 0)
        by_year[y]['types'].append({
            'typeCode': type_code or '기타',
            'hours': hours or 0,
            'regularHours': regular_hours or 0,
            'overtimeHours': overtime_hours or 0,
            'rowsCount': rows_count or 0
        })

    result = []
    for y in sorted(by_year.keys()):
        item = by_year[y]
        total = item['totalHours']
        for t in item['types']:
            t['sharePct'] = round((float(t['hours']) / total * 100), 1) if total > 0 else 0
        item['types'].sort(key=lambda x: float(x['hours']), reverse=True)
        item['totalHours'] = round(total, 2)
        result.append(item)
    return {'years': result}


def get_date_range(conn):
    q = '''
    SELECT MIN(date(substr(EntryTime, 1, 10))) AS minDate,
           MAX(date(substr(EntryTime, 1, 10))) AS maxDate
    FROM dallyproject
    WHERE IFNULL(EntryTime, '') <> ''
      AND date(substr(EntryTime, 1, 10)) IS NOT NULL
    '''
    min_date, max_date = conn.execute(q).fetchone()
    return {'minDate': min_date or '', 'maxDate': max_date or ''}


def get_monthly_person_project(conn, start_date, end_date):
    q = '''
    SELECT substr(d.EntryTime, 1, 7) AS yearMonth,
           d.MemberNo,
           IFNULL(m.korName, '') AS korName,
           IFNULL(m.rankName, '') AS rankName,
           IFNULL(m.teamName, '') AS teamName,
           d.EntryPCode AS projectCode,
           ROUND(SUM(d.TotalHours), 2) AS hours,
           ROUND(SUM(d.RegularHours + IFNULL(d.BusinessTripHours, 0)), 2) AS regularHours,
           ROUND(SUM(d.BusinessTripHours), 2) AS businessTripHours,
           ROUND(SUM(d.OvertimeHours), 2) AS overtimeHours
    FROM dallyproject d
    LEFT JOIN member m ON m.MemberNo = d.MemberNo
    WHERE date(substr(d.EntryTime, 1, 10)) >= date(?)
      AND date(substr(d.EntryTime, 1, 10)) <= date(?)
      AND IFNULL(d.MemberNo, '') <> ''
      AND IFNULL(d.EntryPCode, '') <> ''
    GROUP BY yearMonth, d.MemberNo, IFNULL(m.korName, ''), IFNULL(m.rankName, ''), IFNULL(m.teamName, ''), d.EntryPCode
    HAVING hours > 0
    ORDER BY yearMonth ASC, d.MemberNo ASC, hours DESC
    '''
    cur = conn.execute(q, (start_date, end_date))
    cols = [d[0] for d in cur.description]
    return {'rows': [dict(zip(cols, row)) for row in cur.fetchall()]}


def _norm_title(s):
    v = _as_text(s).strip()
    if not v:
        return ''
    v = re.sub(r'\s+', '', v)
    return v


def load_salary_avg_by_title_year():
    if not os.path.exists(SALARY_AVG_CSV_PATH):
        return {'rows': [], 'byTitle': {}}
    enc = 'cp949'
    rows = []
    with open(SALARY_AVG_CSV_PATH, 'r', encoding=enc, newline='') as f:
        r = csv.DictReader(f)
        for x in r:
            try:
                year = int(_as_text(x.get('year')).strip())
                title = _as_text(x.get('title')).strip()
                avg = int(float(_as_text(x.get('avg_monthly_salary')).strip() or '0'))
                sample = int(float(_as_text(x.get('sample_count')).strip() or '0'))
            except Exception:
                continue
            if not title or avg <= 0:
                continue
            rows.append({
                'year': year,
                'title': title,
                'titleNorm': _norm_title(title),
                'avgMonthlySalary': avg,
                'sampleCount': sample,
            })
    # same (title, year) may appear multiple times; merge with weighted average by sample_count.
    by_title_year = {}
    for row in rows:
        k = (row['titleNorm'], row['year'])
        prev = by_title_year.get(k, {'title': row['title'], 'sumPaid': 0, 'sumSample': 0})
        prev['sumPaid'] += int(row['avgMonthlySalary'] or 0) * int(max(1, row['sampleCount'] or 0))
        prev['sumSample'] += int(max(1, row['sampleCount'] or 0))
        by_title_year[k] = prev

    merged_rows = []
    for (tnorm, year), v in by_title_year.items():
        avg = int(round(v['sumPaid'] / max(1, v['sumSample'])))
        merged_rows.append({
            'year': year,
            'title': v['title'],
            'titleNorm': tnorm,
            'avgMonthlySalary': avg,
            'sampleCount': v['sumSample'],
        })

    by_title = {}
    for row in merged_rows:
        by_title.setdefault(row['titleNorm'], []).append(row)
    for k in by_title.keys():
        by_title[k].sort(key=lambda z: z['year'])
    out = {}
    for k, arr in by_title.items():
        years = [a['year'] for a in arr]
        vals = [a['avgMonthlySalary'] for a in arr]
        min_y, max_y = min(years), max(years)
        min_v = vals[years.index(min_y)]
        max_v = vals[years.index(max_y)]
        growth = 0.03
        if max_y > min_y and min_v > 0 and max_v > 0:
            try:
                growth = (max_v / min_v) ** (1.0 / (max_y - min_y)) - 1.0
            except Exception:
                growth = 0.03
        # 최근 5개 연도 값 기준, 최대/최소 제외 평균(직급 고정 월급)
        arr_desc = sorted(arr, key=lambda z: z['year'], reverse=True)
        last5_vals = [int(x['avgMonthlySalary']) for x in arr_desc[:5] if int(x['avgMonthlySalary'] or 0) > 0]
        trimmed_vals = last5_vals[:]
        if len(trimmed_vals) >= 3:
            mn = min(trimmed_vals)
            mx = max(trimmed_vals)
            mn_removed = False
            mx_removed = False
            kept = []
            for v in trimmed_vals:
                if (not mn_removed) and v == mn:
                    mn_removed = True
                    continue
                if (not mx_removed) and v == mx:
                    mx_removed = True
                    continue
                kept.append(v)
            trimmed_vals = kept if kept else trimmed_vals
        fixed_salary = int(round(sum(trimmed_vals) / len(trimmed_vals))) if trimmed_vals else 0

        out[k] = {
            'title': arr[0]['title'],
            'growthRate': growth,
            'minYear': min_y,
            'maxYear': max_y,
            'yearToSalary': {str(a['year']): a['avgMonthlySalary'] for a in arr},
            'fixedSalary': fixed_salary,
            'fixedBaseYears': [int(x['year']) for x in arr_desc[:5]],
            'rows': arr,
        }
    return {'rows': rows, 'byTitle': out}


class Handler(BaseHTTPRequestHandler):
    def _json(self, status, payload):
        body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
        self.send_response(status)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _html(self, path):
        with open(path, 'rb') as f:
            body = f.read()
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == '/':
            return self._html(INDEX_HTML)
        if parsed.path == '/detail-view.html':
            return self._html(DETAIL_HTML)
        if parsed.path == '/detail-view-project.html':
            return self._html(DETAIL_PROJECT_HTML)
        if parsed.path == '/mysql-preview.html':
            return self._html(MYSQL_PREVIEW_HTML)

        try:
            if parsed.path == '/api/mysql-tables':
                tables = fetch_mysql_tables()
                return self._json(200, {'tables': tables, 'count': len(tables)})

            if parsed.path == '/api/mysql-table-preview':
                q = parse_qs(parsed.query)
                table_name = q.get('table', [''])[0]
                row_limit = q.get('limit', ['100'])[0]
                rows = fetch_mysql_table_preview(table_name, row_limit)
                return self._json(200, {'table': table_name, 'rows': rows, 'count': len(rows)})

            if parsed.path == '/api/stats':
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, get_stats(conn))

            if parsed.path == '/api/people-summary':
                q = parse_qs(parsed.query)
                start_date = q.get('start', ['2016-01-01'])[0]
                end_date = q.get('end', ['2026-12-31'])[0]
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, {'people': get_people_summary(conn, start_date, end_date)})

            if parsed.path == '/api/project-summary':
                q = parse_qs(parsed.query)
                start_date = q.get('start', ['2016-01-01'])[0]
                end_date = q.get('end', ['2026-12-31'])[0]
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, {'projects': get_project_summary(conn, start_date, end_date)})

            if parsed.path == '/api/project-aliases':
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, {'aliases': get_project_alias_map(conn)})

            if parsed.path == '/api/member-dashboard':
                q = parse_qs(parsed.query)
                start_date = q.get('start', ['2016-01-01'])[0]
                end_date = q.get('end', ['2026-12-31'])[0]
                member_no = normalize_member_no(q.get('memberNo', [''])[0])
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, get_member_dashboard(conn, start_date, end_date, member_no))

            if parsed.path == '/api/project-dashboard':
                q = parse_qs(parsed.query)
                start_date = q.get('start', ['2016-01-01'])[0]
                end_date = q.get('end', ['2026-12-31'])[0]
                project_code = (q.get('projectCode', [''])[0] or '').strip()
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, get_project_dashboard(conn, start_date, end_date, project_code))

            if parsed.path == '/api/project-monthly-detail':
                q = parse_qs(parsed.query)
                start_date = q.get('start', ['2016-01-01'])[0]
                end_date = q.get('end', ['2026-12-31'])[0]
                project_code = (q.get('projectCode', [''])[0] or '').strip()
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, get_project_monthly_detail(conn, start_date, end_date, project_code))

            if parsed.path == '/api/member-yearly-breakdown':
                q = parse_qs(parsed.query)
                start_date = q.get('start', ['2016-01-01'])[0]
                end_date = q.get('end', ['2026-12-31'])[0]
                member_no = normalize_member_no(q.get('memberNo', [''])[0])
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, get_member_yearly_type_breakdown(conn, start_date, end_date, member_no))

            if parsed.path == '/api/date-range':
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, get_date_range(conn))

            if parsed.path == '/api/monthly-person-project':
                q = parse_qs(parsed.query)
                start_date = q.get('start', ['2016-01-01'])[0]
                end_date = q.get('end', ['2026-12-31'])[0]
                with sqlite3.connect(DB_PATH) as conn:
                    return self._json(200, get_monthly_person_project(conn, start_date, end_date))

            if parsed.path == '/api/salary-avg-by-title':
                return self._json(200, load_salary_avg_by_title_year())

            self._json(404, {'error': 'Not found'})
        except Exception as e:
            traceback.print_exc()
            self._json(500, {'ok': False, 'error': str(e)})

    def do_POST(self):
        parsed = urlparse(self.path)
        if parsed.path == '/api/sync-ranks':
            try:
                with sqlite3.connect(DB_PATH) as conn:
                    init_db(conn)
                    rank_map = load_member_ranks_from_intranet()
                    updated = update_member_ranks(conn, rank_map)
                return self._json(200, {'ok': True, 'member_rank_source': 'intranet', 'member_rank_loaded': updated})
            except Exception as e:
                traceback.print_exc()
                return self._json(500, {'ok': False, 'error': str(e)})

        if parsed.path != '/api/load':
            return self._json(404, {'error': 'Not found'})

        try:
            with sqlite3.connect(DB_PATH) as conn:
                init_db(conn)
                used = load_from_mysql_into_sqlite(conn)
                try:
                    alias_rows = load_project_alias_from_erp()
                    used['project_alias_source'] = 'erp'
                    replace_project_alias(conn, alias_rows)
                    used['project_alias_loaded'] = len(alias_rows)
                except Exception as e:
                    used['project_alias_source'] = 'erp_failed_keep_existing'
                    used['project_alias_loaded'] = 0
                    used['project_alias_error'] = str(e)
                # member rank는 MySQL member.rankCode -> systemconfig 매핑을 우선 사용
                # (used['member_rank_source'], used['member_rank_loaded']는 load_from_mysql_into_sqlite에서 설정)

                stats = get_stats(conn)

            payload = {
                'ok': True,
                **used,
                'stats': stats,
            }
            self._json(200, payload)
        except Exception as e:
            traceback.print_exc()
            self._json(500, {'ok': False, 'error': str(e)})


def local_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(('8.8.8.8', 80))
        return s.getsockname()[0]
    except Exception:
        return '127.0.0.1'
    finally:
        try:
            s.close()
        except Exception:
            pass


if __name__ == '__main__':
    os.makedirs(BASE_DIR, exist_ok=True)
    with sqlite3.connect(DB_PATH) as conn:
        init_db(conn)
        try:
            alias_rows = load_project_alias_from_erp()
            print(f'Loaded project aliases from ERP: {len(alias_rows)}')
            replace_project_alias(conn, alias_rows)
        except sqlite3.OperationalError as e:
            print(f'Load project aliases skipped due to SQLite lock: {e}')
        except Exception as e:
            print(f'Load project aliases from ERP failed, keep existing aliases: {e}')
        # 직급은 MySQL member.rankCode -> systemconfig 매핑 기반으로 /api/load 시 반영
        try:
            fixed = backfill_daily_hours_if_needed(conn)
            if fixed:
                print(f'Backfilled hour columns for {fixed} rows.')
        except sqlite3.OperationalError as e:
            print(f'Backfill skipped due to SQLite lock: {e}')

    port = int(os.environ.get('PORT', '8090'))
    host = '0.0.0.0'
    print(f'Server running: http://{local_ip()}:{port}')
    print(f'Local access:   http://127.0.0.1:{port}')
    print('Tip: click "DB 로드/갱신" to load from MySQL + ERP alias sync.')
    ThreadingHTTPServer((host, port), Handler).serve_forever()
