import os
import sys
import psycopg2
import psycopg2.extras
import time
import json
import re
import io
import math
import datetime
import configparser
import threading
import urllib.request
import urllib.parse
import uuid
from urllib.parse import quote
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Body, UploadFile, File, Form, Query, BackgroundTasks
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
import pandas as pd
from naver_api import get_access_token, get_payment_done_orders, get_product_order_details, dispatch_orders, confirm_orders, get_product_details
import cafe24_api
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from typing import List, Optional, Dict, Any

# .env 로드
load_dotenv()

# ==========================================
# SSO / 서브경로 호스팅 설정
# ==========================================
APP_ROOT_PATH = os.getenv("APP_ROOT_PATH", "").rstrip("/")
MAIN_LOGIN_URL = os.getenv("MAIN_LOGIN_URL", "https://dbx.no1king.freeddns.org/login")
MAIN_LOGOUT_URL = os.getenv("MAIN_LOGOUT_URL", "https://dbx.no1king.freeddns.org/logout")
SESSION_USER_KEY = os.getenv("SESSION_USER_KEY", "user_email")
SESSION_NAME_KEY = os.getenv("SESSION_NAME_KEY", "user_name")

# 인증 우회 경로 — 정적 자원, 헬스, OAuth 콜백, 로그인/로그아웃 위임
# Starlette scope.path 는 root_path 가 제거된 형태로 들어오므로 prefix 없이 매칭.
_AUTH_EXEMPT_PREFIXES = (
    "/static/",
    "/health",
    "/api/cafe24/callback",
    "/admin/oauth/callback",
    "/login",
    "/logout",
    "/favicon.ico",
)


def _require_session_secret() -> str:
    secret = os.getenv("SESSION_SECRET_KEY", "").strip()
    if not secret:
        raise RuntimeError(
            "SESSION_SECRET_KEY 환경변수가 설정되지 않았습니다. "
            "openssl rand -hex 32 로 새 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요. "
            "dbx-main 과 SSO 하려면 양쪽 .env 에 같은 값이어야 합니다."
        )
    return secret


class AuthGuardMiddleware(BaseHTTPMiddleware):
    """세션에 user_email 이 없으면 차단.

    - HTML 요청 (Accept: text/html 또는 / 같은 페이지) → dbx-main /login?next=... 302
    - 그 외 (API 등) → 401 JSON
    - _AUTH_EXEMPT_PREFIXES 에 해당하는 경로는 통과
    """

    async def dispatch(self, request: Request, call_next):
        # 환경별로 scope.path 가 root_path 를 포함할 수도, 안 할 수도 있어
        # 항상 root_path 제외된 형태로 정규화한다.
        raw = request.scope.get("path", "") or "/"
        if APP_ROOT_PATH and raw.startswith(APP_ROOT_PATH + "/"):
            path = raw[len(APP_ROOT_PATH):]
        elif APP_ROOT_PATH and raw == APP_ROOT_PATH:
            path = "/"
        else:
            path = raw

        for prefix in _AUTH_EXEMPT_PREFIXES:
            if path == prefix or path.startswith(prefix):
                return await call_next(request)

        # 서비스 간 호출 — X-Service-Token 헤더가 일치하면 세션 없이도 통과.
        # NPM 은 외부에서 들어온 이 헤더를 빈 값으로 strip 해야 한다 (보안).
        service_token = os.getenv("INTERNAL_SERVICE_TOKEN", "")
        if service_token:
            incoming_token = request.headers.get("x-service-token", "")
            if incoming_token and incoming_token == service_token:
                return await call_next(request)

        email = request.session.get(SESSION_USER_KEY)
        if email:
            return await call_next(request)

        # 미인증
        accept = request.headers.get("accept", "")
        wants_html = "text/html" in accept or not path.startswith("/api/")
        if wants_html:
            next_path = f"{APP_ROOT_PATH}{path}" if APP_ROOT_PATH else path
            if request.scope.get("query_string"):
                next_path = f"{next_path}?{request.scope['query_string'].decode('latin-1')}"
            sep = "&" if "?" in MAIN_LOGIN_URL else "?"
            target = f"{MAIN_LOGIN_URL}{sep}next={quote(next_path, safe='/')}"
            return RedirectResponse(url=target, status_code=302)
        return JSONResponse({"detail": "Not authenticated"}, status_code=401)

# ==========================================
# 데이터베이스 접속 설정 (환경변수 기반)
# ==========================================
_PG_HOST = os.getenv('POSTGRES_HOST', 'postgres-db')
_PG_PORT = int(os.getenv('POSTGRES_PORT', '5432'))
_PG_USER = os.getenv('POSTGRES_USER', 'king')
_PG_PASSWORD = os.getenv('POSTGRES_PASSWORD', '')

# 1. 메인 프로그램용 DB (품목코드)
DB_CONFIG = {
    'host': _PG_HOST,
    'port': _PG_PORT,
    'dbname': os.getenv('ITEMCODE_DB', 'itemcode_db'),
    'user': _PG_USER,
    'password': _PG_PASSWORD,
}

# 2. 반품 데이터 저장용 DB
RETURN_DB_CONFIG = {
    'host': _PG_HOST,
    'port': _PG_PORT,
    'dbname': os.getenv('RETURN_DB', 'return_db'),
    'user': _PG_USER,
    'password': _PG_PASSWORD,
}

# 3. 주문정보 조회용 DB
ORDER_DB_CONFIG = {
    'host': _PG_HOST,
    'port': _PG_PORT,
    'dbname': os.getenv('ORDERLIST_DB', 'orderlist_db'),
    'user': _PG_USER,
    'password': _PG_PASSWORD,
}
# ==========================================

# Google Sheets
try:
    import gspread
    from google.oauth2.service_account import Credentials
except ImportError:
    gspread = None
    Credentials = None

app = FastAPI(title="CS Web Program")

# 미들웨어 등록 순서 주의:
#   Starlette 은 add_middleware 를 "반대 순서로" 적용한다 (마지막에 추가한 것이 가장 바깥).
#   AuthGuardMiddleware 가 request.session 을 읽으려면 SessionMiddleware 가
#   먼저 실행돼서 scope 에 session 을 주입해야 한다.
#   따라서 AuthGuard 를 먼저 추가 (내부) → Session 을 나중에 추가 (외부, 먼저 실행).
app.add_middleware(AuthGuardMiddleware)
app.add_middleware(
    SessionMiddleware,
    secret_key=_require_session_secret(),
    session_cookie=os.getenv("SESSION_COOKIE_NAME", "session"),
    same_site=os.getenv("SESSION_SAME_SITE", "lax"),
    https_only=os.getenv("SESSION_HTTPS_ONLY", "0") == "1",
    max_age=int(os.getenv("SESSION_MAX_AGE", "28800")),
    path="/",
)


@app.get("/health/db")
async def health_db():
    """DB ping — 인증 불필요. dbx-main 의 헬스 배지가 이 경로를 폴링한다."""
    try:
        conn = psycopg2.connect(**DB_CONFIG, connect_timeout=3)
        conn.close()
        return JSONResponse({"ok": True, "detail": "ok"})
    except Exception as e:
        return JSONResponse({"ok": False, "detail": str(e)}, status_code=503)


@app.get("/login")
async def corm_login(request: Request, next: Optional[str] = None):
    """OMS 와 동일 패턴 — 자체 OAuth 없음. dbx-main 으로 위임."""
    root = APP_ROOT_PATH
    fallback = f"{root}/" if root else "/"
    safe = next if (next and next.startswith("/") and not next.startswith("//") and not next.startswith("/\\")) else fallback
    sep = "&" if "?" in MAIN_LOGIN_URL else "?"
    target = f"{MAIN_LOGIN_URL}{sep}next={quote(safe, safe='/')}"
    return RedirectResponse(url=target, status_code=302)


@app.get("/logout")
async def corm_logout():
    """전적으로 dbx-main 에 위임. CORM 은 세션을 직접 만지지 않는다 (쿠키 path 분리 방지)."""
    return RedirectResponse(url=MAIN_LOGOUT_URL, status_code=302)


from routers.returns import router as returns_router
app.include_router(returns_router)

# Ensure directories exist
os.makedirs("static/css", exist_ok=True)
os.makedirs("static/js", exist_ok=True)
os.makedirs("templates", exist_ok=True)

app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")

# Globals
SMS_HISTORY_FILE = 'sms_history.dat'
SMS_HISTORY_HEADERS = ["일시", "주문유형", "입금액", "주문아이템", "메시지 내용", "이름", "연락처", "주소"]
GSPREAD_CRED_FILE = 'manual-ordering.json'
MANUAL_SPREADSHEET_ID = '1QxFtjDurPPZd8NmtXBy4XEUIzkf93aXD5Mlx5kB33xw'
SETTINGS_SPREADSHEET_ID = '1k7SNEdIaRtGXzQNdJSQAiNSDRwzOePDRTLHY_mNCO8M'
MANUAL_HISTORY_FILE = 'manual_history.dat'

# ----- Utility Functions -----
def first_nonempty(data: dict, *keys: str) -> str:
    for key in keys:
        value = data.get(key)
        if value is not None and str(value).strip():
            return str(value)
    return ""

def read_config(force_refresh=False):
    global _CONFIG_ROWS_CACHE, _CONFIG_ROWS_CACHE_TIME
    
    rows = []
    
    # 1. 캐시 유효성 판단 (force_refresh가 아니고, 캐시가 존재하며, TTL이 지나지 않은 경우)
    if not force_refresh and _CONFIG_ROWS_CACHE is not None:
        if time.time() - _CONFIG_ROWS_CACHE_TIME < CONFIG_CACHE_TTL:
            rows = _CONFIG_ROWS_CACHE
            
    # 2. 캐시가 없거나 만료된 경우 구글 시트에서 직접 새로 로드
    if not rows:
        with _CONFIG_LOCK:
            # 락 대기 중에 다른 스레드에서 캐시를 갱신했을 수 있으므로 다시 검사 (Double-Checked Locking)
            if not force_refresh and _CONFIG_ROWS_CACHE is not None and time.time() - _CONFIG_ROWS_CACHE_TIME < CONFIG_CACHE_TTL:
                rows = _CONFIG_ROWS_CACHE
            else:
                for _ in range(3):
                    try:
                        ws = get_settings_worksheet()
                        rows = ws.get_all_values()
                        break
                    except Exception as e:
                        time.sleep(1.5)
                        print(f"Google Sheets 불러오기 재시도 중...: {e}")
                
                if rows:
                    _CONFIG_ROWS_CACHE = rows
                    _CONFIG_ROWS_CACHE_TIME = time.time()
                    
    # 3. 로드된 로우 데이터를 ConfigParser 형식으로 파싱
    config = configparser.ConfigParser(allow_no_value=True)
    config.optionxform = str
    
    if rows and len(rows) > 1:
        for row in rows:
            if not row or not row[0].strip():
                continue
            section = row[0].strip()
            if section == "대분류 (Section)":
                continue
                
            key = row[1].strip() if len(row) > 1 else ""
            
            c_cells = [x.strip() for x in row[2:]]
            while c_cells and not c_cells[-1]:
                c_cells.pop()
            val = " | ".join(c_cells)
            
            if not config.has_section(section):
                config.add_section(section)
                
            if key:
                if val:
                    config.set(section, key, val)
                else:
                    config.set(section, key)
    return config

def write_config(config):
    global _CONFIG_ROWS_CACHE, _CONFIG_ROWS_CACHE_TIME
    rows = [["대분류 (Section)", "항목 (Key)", "설정값 1", "설정값 2", "설정값 3", "설정값 4", "설정값 5"]]
    for section in config.sections():
        items = config.items(section)
        if not items:
            rows.append([section, "", ""])
        else:
            for k, v in items:
                if v is None:
                    rows.append([section, k])
                else:
                    v_parts = [p.strip() for p in v.split("|")]
                    rows.append([section, k] + v_parts)
                
    for attempt in range(3):
        try:
            ws = get_settings_worksheet()
            ws.clear()
            ws.update(values=rows)
            
            # 구글 시트에 저장이 성공하면 로컬 캐시도 즉시 최신화
            with _CONFIG_LOCK:
                _CONFIG_ROWS_CACHE = rows
                _CONFIG_ROWS_CACHE_TIME = time.time()
            return
        except Exception as e:
            print(f"Google Sheets 저장 시도 {attempt+1}/3 실패: {e}")
            time.sleep(1.5)
            
    print("Google Sheets에 설정을 저장하는 데 최종 실패했습니다.")
    raise Exception("구글 시트 연동 오류 (할당량 초과). 잠시 후 다시 시도해주세요.")

# ----- API Endpoints -----

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    try:
        with open("version_history.json", "r", encoding="utf-8") as f:
            version_history = json.load(f)
    except Exception as e:
        print("버전 히스토리 로드 실패:", e)
        version_history = []

    return templates.TemplateResponse(
        request=request,
        name="index.html",
        context={
            "version_history": version_history,
            "root_path": APP_ROOT_PATH,
            "user_email": request.session.get(SESSION_USER_KEY, ""),
            "user_name": request.session.get(SESSION_NAME_KEY, ""),
        }
    )

@app.get("/api/config")
async def get_config():
    config = read_config()
    data = {}
    for section in config.sections():
        data[section] = dict(config.items(section))
        data[section]["__order"] = list(config.options(section))
    return JSONResponse(data)


@app.post("/api/config/refresh")
async def refresh_config():
    """구글 시트로부터 실시간으로 설정을 동기화하여 캐시를 강제 갱신합니다."""
    try:
        read_config(force_refresh=True)
        return {"status": "success", "message": "설정 캐시가 성공적으로 갱신되었습니다."}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"설정 캐시 갱신 실패: {str(e)}")



class ProductGroup(BaseModel):
    old_code: str
    new_code: str
    name: str

class ProductItem(BaseModel):
    is_separator: bool = False
    code: str = ""
    name: str = ""
    label: str = ""
    price: int = 0
    dprice: int = 0
    color: str = ""
    is_free: bool = False

class ProductsPayload(BaseModel):
    groups: List[ProductGroup]
    products: Dict[str, List[ProductItem]]

@app.post("/api/products/settings")
async def save_products_settings(payload: ProductsPayload):
    config = read_config()
    
    if config.has_section("GROUP_NAMES"):
        config.remove_section("GROUP_NAMES")
    config.add_section("GROUP_NAMES")
    
    for g in payload.groups:
        config.set("GROUP_NAMES", g.new_code, g.name)
        
    for g in payload.groups:
        if config.has_section(g.old_code):
            config.remove_section(g.old_code)
        if config.has_section(g.new_code):
            config.remove_section(g.new_code)
            
    for g in payload.groups:
        config.add_section(g.new_code)
        items = payload.products.get(g.new_code, [])
        sep_idx = 1
        for item in items:
            if item.is_separator:
                config.set(g.new_code, f"---separator_{sep_idx}---", "")
                sep_idx += 1
            else:
                key = f"*{item.label}{item.color}" if item.is_free else f"{item.label}{item.color}"
                val_parts = [item.code, item.name]
                if item.price > 0 or item.dprice > 0:
                    val_parts.append(str(item.price))
                    if item.dprice > 0:
                        val_parts.append(str(item.dprice))
                val = " | ".join(val_parts)
                config.set(g.new_code, key, val)
                
    write_config(config)
    return {"status": "success"}


class OrderItem(BaseModel):
    code: str
    name: str
    quantity: int
    label: str
    item_type: str

class OrderPayload(BaseModel):
    customer_name: str
    address: str
    phone: str
    comment: str
    order_type: str  # e.g., 'noolak', 'pason', 'bullyang', 'ellen', 'mira', 'normal'
    no_lid: bool
    items: List[OrderItem]
    is_ellen: bool = False
    total_amount: int = 0

def get_current_time_info():
    korea_tz = datetime.timezone(datetime.timedelta(hours=9))
    now = datetime.datetime.now(korea_tz)
    time_str = "오후" if now.hour >= 12 else "오전"
    day_names_kr = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"]
    day_name = day_names_kr[now.weekday()]
    date_str = now.strftime(f"%m월 %d일 ({day_name})")
    datetime_str = now.strftime(f"%Y-%m-%d %H:%M:%S ({day_name})")
    return date_str, time_str, datetime_str

def format_phone_number(num_str):
    digits = re.sub(r'\D', '', num_str)
    if len(digits) == 10: return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
    elif len(digits) == 11: return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}"
    elif len(digits) >= 8: return f"{digits[:4]}-{digits[4:]}"
    return num_str





# ----- Google Sheets Config Cache & Global Sheets Cache -----
_CONFIG_ROWS_CACHE = None
_CONFIG_ROWS_CACHE_TIME = 0.0
_CONFIG_LOCK = threading.Lock()
CONFIG_CACHE_TTL = 600.0  # 캐시 유효 시간: 10분

gs_client_global = None
worksheet_manual_global = None
worksheet_settings_global = None
worksheet_sms_history_global = None
worksheet_manual_history_global = None

def get_gspread_client():
    global gs_client_global
    if gspread is None:
        raise Exception("gspread 라이브러리가 없습니다.")
    if gs_client_global is None:
        scopes = ['https://www.googleapis.com/auth/spreadsheets']
        creds = Credentials.from_service_account_file(GSPREAD_CRED_FILE, scopes=scopes)
        gs_client_global = gspread.authorize(creds)
    return gs_client_global

def get_manual_worksheet():
    global worksheet_manual_global
    if worksheet_manual_global is None:
        client = get_gspread_client()
        ss = client.open_by_key(MANUAL_SPREADSHEET_ID)
        ws = ss.get_worksheet(0)
        if ws.title == "시트1":
            try:
                ws.update_title("수동 발주")
            except Exception:
                pass
        worksheet_manual_global = ws
    return worksheet_manual_global

def get_settings_worksheet():
    global worksheet_settings_global
    if worksheet_settings_global is None:
        client = get_gspread_client()
        ss = client.open_by_key(SETTINGS_SPREADSHEET_ID)
        try:
            worksheet_settings_global = ss.worksheet("설정")
        except gspread.exceptions.WorksheetNotFound:
            worksheet_settings_global = ss.add_worksheet(title="설정", rows=500, cols=3)
    return worksheet_settings_global

def get_sms_history_worksheet():
    global worksheet_sms_history_global
    if worksheet_sms_history_global is None:
        client = get_gspread_client()
        ss = client.open_by_key(SETTINGS_SPREADSHEET_ID)
        try:
            worksheet_sms_history_global = ss.worksheet("문자내역")
        except gspread.exceptions.WorksheetNotFound:
            worksheet_sms_history_global = ss.add_worksheet(title="문자내역", rows=1000, cols=10)
            worksheet_sms_history_global.append_row(SMS_HISTORY_HEADERS)
    return worksheet_sms_history_global

def get_manual_history_worksheet():
    global worksheet_manual_history_global
    if worksheet_manual_history_global is None:
        client = get_gspread_client()
        ss = client.open_by_key(SETTINGS_SPREADSHEET_ID)
        try:
            worksheet_manual_history_global = ss.worksheet("수동발주내역")
        except gspread.exceptions.WorksheetNotFound:
            worksheet_manual_history_global = ss.add_worksheet(title="수동발주내역", rows=1000, cols=3)
            worksheet_manual_history_global.append_row(["일시", "고객명", "주문금액"])
    return worksheet_manual_history_global

@app.on_event("startup")
async def startup_event():
    init_db()
    try:
        print("구글 시트 연동 초기화 중...")
        get_manual_worksheet()
        get_settings_worksheet()
        get_sms_history_worksheet()
        get_manual_history_worksheet()
        print("구글 시트 연동 완료!")
        
        # 캐시 초기 적재 (Warm up cache)
        print("구글 시트 설정 캐시 초기 적재 시작...")
        read_config(force_refresh=True)
        print("구글 시트 설정 캐시 적재 완료!")
    except Exception as e:
        print(f"구글 시트 연동 초기화 실패 (사용 전 권한을 확인하세요): {str(e)}")


@app.post("/api/order/submit")
async def submit_order_submit(payload: OrderPayload):
    date_str, time_str, current_datetime_str = get_current_time_info()
    c_tel = format_phone_number(payload.phone)
    
    if payload.is_ellen:
        title = "수동발주(엘렌)"
    else:
        title_map = {'noolak': '누락', 'pason': '파손', 'bullyang': '불량', 'normal': '수동발주'}
        title = title_map.get(payload.order_type, "수동발주")
        
    if not payload.customer_name:
        title = "재구매"
    
    try:
        worksheet = get_manual_worksheet()
        
        rows = []
        lines = []
        for i, item in enumerate(payload.items):
            lid_suffix = "(뚜껑 없이 통만)" if payload.no_lid and item.item_type == "ORDER_SINGLE" else ""
            sub_text = f"{title}, {item.name}{lid_suffix} {item.quantity}개{' 발송' if lid_suffix else ''}"
            lines.append(f"{payload.customer_name} 고객님 {item.name}{lid_suffix} {item.quantity}개 발송, {title}")
            
            c_comment = payload.comment or "빠른배송 부탁드립니다."
            c_amount = payload.total_amount if i == len(payload.items) - 1 else ""
            row_data = [current_datetime_str, "", c_amount, payload.customer_name, item.code, item.name, item.quantity, payload.address, "", c_tel, c_tel, c_comment, "", title, sub_text]
            rows.append(row_data)
        
        if rows:
            worksheet.append_rows(rows, value_input_option='USER_ENTERED')
            
        clipboard_text = "\r\n".join(lines) + "\r\n"
        return {"status": "success", "message": f"{len(rows)}개 항목 구글 시트 입력 및 클립보드 준비 완료.", "clipboard_text": clipboard_text, "timestamp": current_datetime_str}
    except Exception as e:
        err_str = str(e)
        if not err_str or err_str.strip() == "{}":
            err_str = repr(e)
        raise HTTPException(status_code=500, detail=f"수동발주 시트 접근 에러 (스프레드시트에 이메일 편집 권한을 추가했는지 확인해주세요!): {err_str}")

@app.post("/api/sheet/download_and_clear")
async def download_and_clear_sheet():
    try:
        scopes = ['https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive']
        creds = Credentials.from_service_account_file(GSPREAD_CRED_FILE, scopes=scopes)
        
        import google.auth.transport.requests
        request = google.auth.transport.requests.Request()
        creds.refresh(request)
        access_token = creds.token
        
        worksheet = get_manual_worksheet()
        sheet_gid = worksheet.id
        
        url = f"https://docs.google.com/spreadsheets/export?id={MANUAL_SPREADSHEET_ID}&exportFormat=xlsx"
        req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"})
        with urllib.request.urlopen(req) as response:
            content = response.read()
            
        korea_tz = datetime.timezone(datetime.timedelta(hours=9))
        now = datetime.datetime.now(korea_tz)
        weekdays = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"]
        weekday_str = weekdays[now.weekday()]
        date_str = f"{now.strftime('%m')}월 {now.strftime('%d')}일 ({weekday_str})"
        filename = f"{date_str} 5 수동발주.xlsx"
        
        # Clear sheet values instantly
        worksheet.batch_clear(["A2:Z10000"])
        
        # Clear formatting (colors, borders, etc) from row 2 downwards
        body = {
            "requests": [
                {
                    "updateCells": {
                        "range": {
                            "sheetId": worksheet.id,
                            "startRowIndex": 1
                        },
                        "fields": "userEnteredFormat"
                    }
                }
            ]
        }
        worksheet.spreadsheet.batch_update(body)
            
        encoded_filename = urllib.parse.quote(filename)
        headers = {
            "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
        }
        return StreamingResponse(
            io.BytesIO(content),
            media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            headers=headers
        )
    except Exception as e:
        err_str = str(e)
        if not err_str or err_str.strip() == "{}":
            err_str = repr(e)
        raise HTTPException(status_code=500, detail=f"저장 및 삭제 실패: {err_str}")



@app.get("/api/sms/history")
async def get_history():
    local_history = {}
    def merge_history_item(sheet_item, local_item):
        if not local_item:
            return sheet_item
        merged = dict(sheet_item)
        merged.update(local_item)
        for key in ("custName", "custPhone", "custAddress", "customer_name", "phone", "address"):
            if not merged.get(key) and sheet_item.get(key):
                merged[key] = sheet_item[key]
        return merged

    if os.path.exists(SMS_HISTORY_FILE):
        with open(SMS_HISTORY_FILE, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    try:
                        item = json.loads(line)
                        if "timestamp" in item:
                            local_history[item["timestamp"]] = item
                    except:
                        pass

    history = []
    try:
        ws = get_sms_history_worksheet()
        rows = ws.get_all_values()
        if rows and len(rows) > 1:
            for row in rows[1:]:
                if not row or not row[0].strip():
                    continue
                timestamp = row[0]

                order_type = row[1] if len(row) > 1 else ""
                deposit_amt = row[2] if len(row) > 2 else ""
                items_str = row[3] if len(row) > 3 else ""
                preview_text = row[4] if len(row) > 4 else ""
                cust_name = row[5] if len(row) > 5 else ""
                cust_phone = row[6] if len(row) > 6 else ""
                cust_address = row[7] if len(row) > 7 else ""
                preview_text = preview_text.replace("  ", "\n")

                sheet_item = {
                    "timestamp": timestamp,
                    "orderType": order_type,
                    "deposit_amount_str": deposit_amt,
                    "items_str": items_str,
                    "previewText": preview_text,
                    "custName": cust_name,
                    "custPhone": cust_phone,
                    "custAddress": cust_address,
                    "customer_name": cust_name,
                    "phone": cust_phone,
                    "address": cust_address
                }
                history.append(merge_history_item(sheet_item, local_history.get(timestamp)))
        return history
    except Exception as e:
        print(f"구글 시트에서 기록을 읽어오는 중 에러: {e}")
        
    if os.path.exists(SMS_HISTORY_FILE):
        return [json.loads(line) for line in open(SMS_HISTORY_FILE, 'r', encoding='utf-8') if line.strip()]
    return []

@app.post("/api/sms/history")
async def add_history(data: dict):
    with open(SMS_HISTORY_FILE, 'a', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False)
        f.write('\n')
        
    # 구글 시트에 기록
    try:
        ws = get_sms_history_worksheet()
        
        # 주문 품목 텍스트화
        items_str = ""
        items = data.get("items", [])
        if items:
            items_str = ", ".join([f"{i.get('label', '')}[{i.get('code', '')}] {i.get('qty', 0)}개" for i in items])
            
        # 행 높이가 길어지지 않도록 줄바꿈을 띄어쓰기로 변경
        preview_text = data.get("previewText", "").replace("\n", "  ").replace("\r", "")
        customer_name = first_nonempty(data, "custName", "customer_name", "customerName", "name")
        customer_phone = first_nonempty(data, "custPhone", "phone", "customer_phone", "customerPhone")
        customer_address = first_nonempty(data, "custAddress", "address", "customer_address", "customerAddress")

        row = [
            data.get("timestamp", ""),
            data.get("orderType", ""),
            data.get("deposit_amount_str", ""),
            items_str,
            preview_text,
            customer_name,
            customer_phone,
            customer_address
        ]
        next_row = len(ws.col_values(1)) + 1
        ws.update(values=[row], range_name=f"A{next_row}:H{next_row}", value_input_option='USER_ENTERED')
    except Exception as e:
        print(f"구글 시트 문자내역 기록 실패: {e}")
        
    return {"status": "success"}

@app.delete("/api/sms/history")
async def clear_history():
    if os.path.exists(SMS_HISTORY_FILE):
        os.remove(SMS_HISTORY_FILE)
        
    try:
        ws = get_sms_history_worksheet()
        ws.batch_clear(["A2:Z10000"])
    except Exception as e:
        print(f"구글 시트 문자내역 전체 삭제 실패: {e}")
        
    return {"status": "success"}

@app.delete("/api/sms/history/{index}")
async def delete_history_item(index: int):
    try:
        ws = get_sms_history_worksheet()
        rows = ws.get_all_values()
        timestamp_to_delete = None
        if len(rows) > index + 1:
            timestamp_to_delete = rows[index + 1][0] if len(rows[index+1]) > 0 else None
            
        ws.delete_rows(index + 2)
        
        if timestamp_to_delete and os.path.exists(SMS_HISTORY_FILE):
            local_history = []
            with open(SMS_HISTORY_FILE, 'r', encoding='utf-8') as f:
                for line in f:
                    if line.strip():
                        try:
                            local_history.append(json.loads(line))
                        except:
                            pass
            
            local_history = [item for item in local_history if item.get("timestamp") != timestamp_to_delete]
            
            with open(SMS_HISTORY_FILE, 'w', encoding='utf-8') as f:
                for entry in local_history:
                    json.dump(entry, f, ensure_ascii=False)
                    f.write('\n')
    except Exception as e:
        print(f"구글 시트 문자내역 항목 삭제 실패: {e}")
            
    return {"status": "success"}


import sys

@app.get("/api/manual/history")
async def get_manual_history():
    korea_tz = datetime.timezone(datetime.timedelta(hours=9))
    today_prefix = datetime.datetime.now(korea_tz).strftime("%Y-%m-%d")
    
    local_history = {}
    if os.path.exists(MANUAL_HISTORY_FILE):
        with open(MANUAL_HISTORY_FILE, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    try:
                        item = json.loads(line)
                        if item.get("timestamp", "").startswith(today_prefix):
                            local_history[item["timestamp"]] = item
                    except:
                        pass
                        
    history = []
    try:
        ws = get_manual_history_worksheet()
        rows = ws.get_all_values()
        if rows and len(rows) > 1:
            for row in rows[1:]:
                if not row or not row[0].strip():
                    continue
                timestamp = row[0]
                if not timestamp.startswith(today_prefix):
                    continue
                    
                if timestamp in local_history:
                    history.append(local_history[timestamp])
                else:
                    customer_name = row[1] if len(row) > 1 else ""
                    total_amount_str = str(row[2]).replace(",", "") if len(row) > 2 else "0"
                    total_amount = int(total_amount_str) if total_amount_str.lstrip('-').isdigit() else total_amount_str
                    history.append({
                        "timestamp": timestamp,
                        "customer_name": customer_name,
                        "total_amount": total_amount
                    })
        return history
    except Exception as e:
        print(f"구글 시트에서 수동발주 기록을 읽어오는 중 에러: {e}")
        
    return list(local_history.values())

@app.post("/api/manual/history")
async def add_manual_history(data: dict):
    local_history = []
    korea_tz = datetime.timezone(datetime.timedelta(hours=9))
    today_prefix = datetime.datetime.now(korea_tz).strftime("%Y-%m-%d")
    
    if os.path.exists(MANUAL_HISTORY_FILE):
        with open(MANUAL_HISTORY_FILE, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    try:
                        item = json.loads(line)
                        if item.get("timestamp", "").startswith(today_prefix):
                            local_history.append(item)
                    except:
                        pass
                        
    local_history.append(data)
    
    with open(MANUAL_HISTORY_FILE, 'w', encoding='utf-8') as f:
        for entry in local_history:
            json.dump(entry, f, ensure_ascii=False)
            f.write('\n')
            
    try:
        ws = get_manual_history_worksheet()
        row = [
            data.get("timestamp", ""),
            data.get("customer_name", ""),
            data.get("total_amount", 0)
        ]
        ws.append_row(row, value_input_option='USER_ENTERED')
    except Exception as e:
        print(f"구글 시트 수동발주내역 기록 실패: {e}")
            
    return {"status": "success"}

@app.delete("/api/manual/history/{timestamp}")
async def delete_manual_history(timestamp: str):
    # 구글 시트에서 삭제
    try:
        ws = get_manual_worksheet()
        rows = ws.get_all_values()
        start_idx = None
        end_idx = None
        for i, row in enumerate(rows):
            if row and row[0] == timestamp:
                if start_idx is None:
                    start_idx = i + 1
                end_idx = i + 1
        if start_idx:
            ws.delete_rows(start_idx, end_idx)
    except Exception as e:
        print(f"구글 시트 수동발주 삭제 실패: {e}")
        
    try:
        ws2 = get_manual_history_worksheet()
        rows2 = ws2.get_all_values()
        for i, row in enumerate(rows2):
            if row and row[0] == timestamp:
                ws2.delete_rows(i + 1)
                break
    except Exception as e:
        print(f"구글 시트 수동발주내역 시트 삭제 실패: {e}")
        
    # 로컬에서 삭제
    if os.path.exists(MANUAL_HISTORY_FILE):
        local_history = []
        with open(MANUAL_HISTORY_FILE, 'r', encoding='utf-8') as f:
            for line in f:
                if line.strip():
                    try:
                        local_history.append(json.loads(line))
                    except:
                        pass
        local_history = [item for item in local_history if item.get("timestamp") != timestamp]
        with open(MANUAL_HISTORY_FILE, 'w', encoding='utf-8') as f:
            for entry in local_history:
                json.dump(entry, f, ensure_ascii=False)
                f.write('\n')
    return {"status": "success"}

@app.get("/api/fonts")
async def get_fonts():
    fonts = set()
    if sys.platform == 'win32':
        try:
            import winreg
            for hkey in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
                try:
                    key = winreg.OpenKey(hkey, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts")
                    for i in range(0, winreg.QueryInfoKey(key)[1]):
                        name, _, _ = winreg.EnumValue(key, i)
                        font_name = name.split(' (')[0].strip()
                        if font_name:
                            fonts.add(font_name)
                    winreg.CloseKey(key)
                except Exception:
                    pass
        except Exception as e:
            print(f"폰트 불러오기 오류: {e}")
            
    if not fonts:
        fonts_list = ["Inter", "Malgun Gothic", "Arial", "sans-serif"]
    else:
        fonts_list = sorted(list(fonts))
    return fonts_list

@app.post("/api/gift/settings")
async def save_gift_settings(payload: dict):
    # 하위 호환성 (기존 rules만 오는 경우)
    if "rules" in payload:
        rules = payload["rules"]
        app_settings = payload.get("app_settings", {})
    else:
        rules = payload
        app_settings = {}

    config = read_config()
    section = "SMS_GIFT_RULES"
    if section not in config:
        config.add_section(section)
    else:
        config.remove_section(section)
        config.add_section(section)
        
    for k, v in rules.items():
        if v:
            config.set(section, k, str(v))
            
    if app_settings:
        app_section = "APP_SETTINGS"
        if app_section not in config:
            config.add_section(app_section)
        else:
            config.remove_section(app_section)
            config.add_section(app_section)
            
        for k, v in app_settings.items():
            if v:
                config.set(app_section, k, str(v))
            
    write_config(config)
    return {"status": "success"}

# ----- Code DB (SQLite) Data Definition -----
DB_PATH = "codes.db"

def init_db():
    conn = psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
    cursor = conn.cursor()
    
    # 단품 테이블
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS single_items (
            item_code TEXT PRIMARY KEY,
            sabangnet_code TEXT,
            name TEXT
        )
    ''')
    # 세트 테이블
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS set_items (
            item_code TEXT PRIMARY KEY,
            sabangnet_code TEXT,
            name TEXT
        )
    ''')
    # 세트 구성품
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS set_components (
            set_code TEXT,
            single_code TEXT,
            quantity INTEGER,
            PRIMARY KEY (set_code, single_code),
            FOREIGN KEY(set_code) REFERENCES set_items(item_code) ON DELETE CASCADE,
            FOREIGN KEY(single_code) REFERENCES single_items(item_code) ON DELETE CASCADE
        )
    ''')
    conn.commit()
    conn.close()

    # 반품 데이터베이스 (return_db) 초기화 시도
    try:
        conn_return = psycopg2.connect(**RETURN_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
        cursor_return = conn_return.cursor()
        
        # 반품 신청 테이블
        cursor_return.execute('''
            CREATE TABLE IF NOT EXISTS return_requests (
                id SERIAL PRIMARY KEY,
                request_date VARCHAR(20),
                request_type VARCHAR(50),
                receiver_name VARCHAR(100),
                address TEXT,
                phone VARCHAR(50),
                tracking_no VARCHAR(100),
                mall VARCHAR(100),
                remarks TEXT,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ''')
        
        # 반품 입고 테이블
        cursor_return.execute('''
            CREATE TABLE IF NOT EXISTS return_receivings (
                id SERIAL PRIMARY KEY,
                receive_date VARCHAR(20),
                tracking_no VARCHAR(100),
                mall VARCHAR(100),
                receiver_name VARCHAR(100),
                phone VARCHAR(50),
                remarks TEXT,
                receive_status VARCHAR(50) DEFAULT '환불 대기',
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ''')
        # 혹시 기존에 생성된 테이블에 receive_status 컬럼이 없다면 추가
        try:
            cursor_return.execute('ALTER TABLE return_receivings ADD COLUMN IF NOT EXISTS receive_status VARCHAR(50) DEFAULT \'환불 대기\'')
            cursor_return.execute('ALTER TABLE return_receivings ADD COLUMN IF NOT EXISTS address TEXT')
        except:
            pass
        conn_return.commit()
        conn_return.close()
    except Exception as e:
        print(f"[Warning] return_db 초기화 실패. 데이터베이스가 아직 생성되지 않았거나 권한 문제가 있을 수 있습니다: {e}")

def get_db_conn():
    conn = psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
    return conn

def get_return_db_conn():
    conn = psycopg2.connect(**RETURN_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
    return conn

def get_order_db_conn():
    conn = psycopg2.connect(**ORDER_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
    return conn

# Code API Models
class SingleItemAdd(BaseModel):
    item_code: str
    sabangnet_code: str
    name: str

class SetComponentData(BaseModel):
    single_code: str
    quantity: int

class SetItemAdd(BaseModel):
    item_code: str
    components: List[SetComponentData]
    sabangnet_code: str = ""
    name: str = ""

@app.get("/api/codes/single")
async def get_single_items():
    conn = get_db_conn()
    cursor = conn.cursor()
    cursor.execute("SELECT * FROM single_items ORDER BY item_code ASC")
    rows = cursor.fetchall()
    conn.close()
    return {"status": "success", "data": [dict(r) for r in rows]}

@app.post("/api/codes/single")
async def add_single_item(payload: SingleItemAdd):
    conn = get_db_conn()
    cursor = conn.cursor()
    try:
        cursor.execute(
            "INSERT INTO single_items (item_code, sabangnet_code, name) VALUES (%s, %s, %s)",
            (payload.item_code, payload.sabangnet_code, payload.name)
        )
        conn.commit()
        return {"status": "success"}
    except psycopg2.IntegrityError:
        conn.close()
        raise HTTPException(status_code=400, detail="이미 존재하는 단품 코드입니다.")
    finally:
        if conn:
            conn.close()

@app.put("/api/codes/single/{item_code}")
async def update_single_item(item_code: str, payload: SingleItemAdd):
    conn = get_db_conn()
    cursor = conn.cursor()
    cursor.execute(
        "UPDATE single_items SET sabangnet_code=%s, name=%s WHERE item_code=%s",
        (payload.sabangnet_code, payload.name, item_code)
    )
    conn.commit()
    conn.close()
    return {"status": "success"}

@app.delete("/api/codes/single/{item_code}")
async def delete_single_item(item_code: str):
    conn = get_db_conn()
    cursor = conn.cursor()
    cursor.execute("DELETE FROM single_items WHERE item_code=%s", (item_code,))
    conn.commit()
    conn.close()
    return {"status": "success"}


@app.get("/api/codes/set")
async def get_set_items():
    conn = get_db_conn()
    cursor = conn.cursor()
    cursor.execute("SELECT * FROM set_items ORDER BY item_code ASC")
    sets = cursor.fetchall()
    
    result = []
    for s in sets:
        s_dict = dict(s)
        cursor.execute("SELECT single_code, quantity FROM set_components WHERE set_code=%s", (s_dict["item_code"],))
        comps = cursor.fetchall()
        s_dict["components"] = [dict(c) for c in comps]
        result.append(s_dict)
        
    conn.close()
    return {"status": "success", "data": result}
    
@app.post("/api/codes/set")
async def add_set_item(payload: SetItemAdd):
    conn = get_db_conn()
    cursor = conn.cursor()
    try:
        cursor.execute(
            "INSERT INTO set_items (item_code, sabangnet_code, name) VALUES (%s, %s, %s)",
            (payload.item_code, payload.sabangnet_code, payload.name)
        )
        for comp in payload.components:
            cursor.execute(
                "INSERT INTO set_components (set_code, single_code, quantity) VALUES (%s, %s, %s)",
                (payload.item_code, comp.single_code, comp.quantity)
            )
        conn.commit()
        return {"status": "success"}
    except psycopg2.IntegrityError:
        conn.rollback()
        raise HTTPException(status_code=400, detail="중복되는 세트 코드이거나 구성품에 존재하지 않는 단품 코드가 있습니다.")
    finally:
        if conn: conn.close()

@app.put("/api/codes/set/{item_code}")
async def update_set_item(item_code: str, payload: SetItemAdd):
    conn = get_db_conn()
    cursor = conn.cursor()
    try:
        cursor.execute("UPDATE set_items SET sabangnet_code=%s, name=%s WHERE item_code=%s", (payload.sabangnet_code, payload.name, item_code))
        cursor.execute("DELETE FROM set_components WHERE set_code=%s", (item_code,))
        for comp in payload.components:
            cursor.execute(
                "INSERT INTO set_components (set_code, single_code, quantity) VALUES (%s, %s, %s)",
                (item_code, comp.single_code, comp.quantity)
            )
        conn.commit()
        return {"status": "success"}
    except psycopg2.IntegrityError:
        conn.rollback()
        raise HTTPException(status_code=400, detail="구성품 업데이트 중 오류 발생. 유효한 단품 코드인지 확인해주세요.")
    finally:
        if conn: conn.close()
        
@app.delete("/api/codes/set/{item_code}")
async def delete_set_item(item_code: str):
    conn = get_db_conn()
    cursor = conn.cursor()
    cursor.execute("DELETE FROM set_items WHERE item_code=%s", (item_code,))
    conn.commit()
    conn.close()
    return {"status": "success"}

# ----- 스마트스토어 & 카페24 자동발주 공통 -----

EXCEL_COLUMNS = [
    '주문날짜', '번호', '주문번호', '수령인명', '상품코드', '상품명', '수량', 
    '주소', '우편번호', '수령인 전화번호', '수령인 휴대폰', '배송시 요구사항', 
    '송장번호', '쇼핑몰명', '비고', '주문번호(쇼핑몰)', '주문목록'
]

def generate_order_excel(data, numeric_date, filename, is_cafe24=False, return_bytes=False):
    df = pd.DataFrame(data, columns=EXCEL_COLUMNS)
    
    address_replacements = {
        "경기 ": "경기도 ",
        "강원도 ": "강원특별자치도 ",
        "경남 ": "경상남도 ",
        "경북 ": "경상북도 ",
        "광주 ": "광주광역시 ",
        "대구 ": "대구광역시 ",
        "대전 ": "대전광역시 ",
        "부산 ": "부산광역시 ",
        "서울 ": "서울특별시 ",
        "울산 ": "울산광역시 ",
        "인천 ": "인천광역시 ",
        "전남 ": "전라남도 ",
        "전북 ": "전라북도 ",
        "충남 ": "충청남도 ",
        "충북 ": "충청북도 "
    }
    
    if '주소' in df.columns:
        for old_val, new_val in address_replacements.items():
            df['주소'] = df['주소'].str.replace(old_val, new_val, regex=False)
            
    df = df.sort_values(by=['주소', '주문번호']).reset_index(drop=True)
    df['번호'] = range(1, len(df) + 1)
    
    output = io.BytesIO()
    with pd.ExcelWriter(output, engine='openpyxl') as writer:
        df.to_excel(writer, index=False, sheet_name='Sheet1')
        
        worksheet = writer.sheets['Sheet1']
        from openpyxl.utils import get_column_letter
        from openpyxl.styles import Alignment, PatternFill, Font
        
        if is_cafe24:
            header_fill = PatternFill(start_color="002060", end_color="002060", fill_type="solid")
        else:
            header_fill = PatternFill(start_color="009900", end_color="009900", fill_type="solid")
            
        header_font = Font(name="나눔고딕", bold=True, color="FFFFFF")
        data_font = Font(name="나눔고딕")
        center_alignment = Alignment(horizontal="center", vertical="center")
        
        for row in worksheet.iter_rows(min_row=1, max_row=worksheet.max_row, min_col=1, max_col=17):
            for cell in row:
                if cell.row == 1:
                    cell.fill = header_fill
                    cell.font = header_font
                    cell.alignment = center_alignment
                else:
                    cell.font = data_font
            
        duplicate_groups = {}
        for i, rowTuple in df.iterrows():
            key = (rowTuple['수령인명'], rowTuple['주소'])
            if key not in duplicate_groups:
                duplicate_groups[key] = []
            duplicate_groups[key].append(i + 2)
            
        color_60_lighter = PatternFill(start_color="B4C6E7", end_color="B4C6E7", fill_type="solid")
        color_40_lighter = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid")
        current_toggle = True
        
        for key, row_indices in duplicate_groups.items():
            if len(row_indices) > 1:
                fill = color_60_lighter if current_toggle else color_40_lighter
                for r_idx in row_indices:
                    for c_idx in range(1, 18):
                        worksheet.cell(row=r_idx, column=c_idx).fill = fill
                current_toggle = not current_toggle
        
        worksheet.freeze_panes = "H2"
        worksheet.auto_filter.ref = worksheet.dimensions
        
        for idx, col in enumerate(df.columns):
            col_letter = get_column_letter(idx + 1)
            max_length = 0
            for cell in worksheet[col_letter]:
                if cell.value is not None:
                    val_str = str(cell.value)
                    length = sum(1.8 if ord(c) > 127 else 1.1 for c in val_str)
                    if length > max_length:
                        max_length = length
            
            adjusted_width = max_length + 2
            if adjusted_width > 60:
                adjusted_width = 60
            worksheet.column_dimensions[col_letter].width = adjusted_width
            
    output.seek(0)
    
    encoded_filename = urllib.parse.quote(filename)
    if return_bytes:
        return output.getvalue(), encoded_filename
        
    headers = {
        "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
    }
    return StreamingResponse(
        output,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers=headers
    )

def get_all_code_to_name_from_db():
    import psycopg2
    import psycopg2.extras
    import os
    code_to_name = {}
    try:
        conn = psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor)
        cur = conn.cursor()
        cur.execute("SELECT item_code, name FROM single_items")
        for row in cur.fetchall():
            code_to_name[row[0]] = row[1]
        cur.execute("SELECT item_code, name FROM set_items")
        for row in cur.fetchall():
            code_to_name[row[0]] = row[1]
        conn.close()
    except Exception as e:
        print(f"DB 매핑 오류: {e}")
    return code_to_name

@app.post("/api/smartstore/start_download")
async def start_smartstore_download(background_tasks: BackgroundTasks, confirm: bool = Query(False)):
    task_id = str(uuid.uuid4())
    task_store[task_id] = {
        "status": "processing",
        "current": 0,
        "total": 0,
        "file_bytes": None,
        "filename": None,
        "error": None
    }
    
    def run_task(tid):
        try:
            token = get_access_token()
            statuses = get_payment_done_orders(token)
            
            if not statuses:
                task_store[tid]["status"] = "error"
                task_store[tid]["error"] = "새로운 결제완료(배송대기) 주문이 없습니다."
                task_store[tid]["status_code"] = 404
                return
            
            po_ids = [s.get('productOrderId') for s in statuses if s.get('productOrderId')]
            task_store[tid]["total"] = len(po_ids)
            
            all_order_details = []
            chunk_size = 100
            for i in range(0, len(po_ids), chunk_size):
                chunk = po_ids[i:i+chunk_size]
                details = get_product_order_details(token, chunk)
                all_order_details.extend(details)
                task_store[tid]["current"] += len(chunk)
                
            if confirm:
                for i in range(0, len(po_ids), chunk_size):
                    chunk = po_ids[i:i+chunk_size]
                    confirm_orders(token, chunk)
            
            data = []
            now = datetime.datetime.now()
            numeric_date = int(now.strftime('%Y%m%d'))
            
            product_cache = {}
            code_to_name = get_all_code_to_name_from_db()
            
            for idx, detail in enumerate(all_order_details):
                po = detail.get('productOrder', {})
                order = detail.get('order', {})
                shippingAddress = po.get('shippingAddress', {})
                
                product_id = po.get('productId')
                option_code = str(po.get('optionCode', ''))
                seller_code = ''
                
                if product_id:
                    if product_id not in product_cache:
                        product_cache[product_id] = get_product_details(token, product_id)
                    
                    p_data = product_cache[product_id]
                    seller_code = p_data.get('originProduct', {}).get('detailAttribute', {}).get('sellerCodeInfo', {}).get('sellerManagementCode', '')
                    
                    if not seller_code and option_code:
                        option_info = p_data.get('originProduct', {}).get('detailAttribute', {}).get('optionInfo', {})
                        found_opt_code = ''
                        for opt in option_info.get('optionCombinations', []):
                            if str(opt.get('id', '')) == option_code:
                                found_opt_code = opt.get('sellerManagerCode', '')
                                break
                        if not found_opt_code:
                            for opt in option_info.get('optionStandards', []):
                                if str(opt.get('id', '')) == option_code:
                                    found_opt_code = opt.get('sellerManagerCode', '')
                                    break
                        if found_opt_code:
                            seller_code = found_opt_code
                            
                final_quantity = int(po.get('quantity', 1))
                if seller_code and '_' in seller_code:
                    parts = seller_code.rsplit('_', 1)
                    if len(parts) == 2 and parts[1].isdigit():
                        seller_code = parts[0]
                        final_quantity = final_quantity * int(parts[1])
                    
                base_address = shippingAddress.get('baseAddress', '')
                detailed_address = shippingAddress.get('detailedAddress', '')
                full_address = f"{base_address} {detailed_address}".strip()
                
                final_item_code = seller_code or po.get('optionManageCode', '') or po.get('sellerProductCode', '')
                original_product_name = po.get('productName', '')
                product_name = code_to_name.get(final_item_code, original_product_name)
                
                option_info = po.get('productOption', '')
                if option_info:
                    order_list = f"{product_name} [{option_info}]=/{po.get('quantity', 1)}"
                else:
                    order_list = f"{product_name}=/{po.get('quantity', 1)}"
                
                row = {
                    '주문날짜': numeric_date,
                    '번호': idx + 1,
                    '주문번호': po.get('productOrderId', ''),
                    '수령인명': shippingAddress.get('name', ''),
                    '상품코드': final_item_code,
                    '상품명': product_name,
                    '수량': final_quantity,
                    '주소': full_address,
                    '우편번호': '',
                    '수령인 전화번호': shippingAddress.get('tel1', ''),
                    '수령인 휴대폰': shippingAddress.get('tel1', ''),
                    '배송시 요구사항': po.get('shippingMemo', ''),
                    '송장번호': '',
                    '쇼핑몰명': '스마트스토어',
                    '비고': '',
                    '주문번호(쇼핑몰)': order.get('orderId', ''),
                    '주문목록': order_list
                }
                data.append(row)
            
            weekdays = ['월요일', '화요일', '수요일', '목요일', '금요일', '토요일', '일요일']
            weekday_str = weekdays[now.weekday()]
            if now.hour >= 15:
                filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 1 미라네 스마트 스토어-3시.xlsx"
            else:
                filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 1 미라네 스마트 스토어-오전.xlsx"
                
            file_bytes, enc_name = generate_order_excel(data, numeric_date, filename, return_bytes=True)
            
            task_store[tid]["current"] = task_store[tid]["total"]
            task_store[tid]["file_bytes"] = file_bytes
            task_store[tid]["filename"] = enc_name
            task_store[tid]["status"] = "completed"

        except Exception as e:
            task_store[tid]["status"] = "error"
            task_store[tid]["error"] = str(e)
            task_store[tid]["status_code"] = 500

    background_tasks.add_task(run_task, task_id)
    return {"status": "success", "task_id": task_id}


@app.post("/api/upload_invoices")
async def upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str = Form("CJGLS")):
    try:
        content = await file.read()
        
        if file.filename.endswith('.xls'):
            try:
                df = pd.read_excel(io.BytesIO(content), engine='xlrd')
            except Exception:
                try:
                    df = pd.read_html(io.BytesIO(content))[0]
                except Exception as e2:
                    return JSONResponse({"status": "error", "message": f"xls 파일 파싱 실패: {str(e2)}"}, status_code=400)
        else:
            df = pd.read_excel(io.BytesIO(content), engine='openpyxl')
            
        if '주문번호(쇼핑몰)' not in df.columns or '송장번호' not in df.columns:
            return JSONResponse({"status": "error", "message": "엑셀에 '주문번호(쇼핑몰)' 또는 '송장번호' 컬럼이 없습니다."}, status_code=400)
            
        df = df[df['송장번호'].notna() & (df['송장번호'] != '')]
        
        if df.empty:
            return JSONResponse({"status": "error", "message": "송장번호가 입력된 데이터가 없습니다."}, status_code=400)
            
        dispatch_list = []
        for index, row in df.iterrows():
            po_id = str(row['주문번호(쇼핑몰)']).replace('.0', '').strip()
            tracking_num = str(row['송장번호']).replace('.0', '').strip()
            
            if ',' in tracking_num:
                tracking_num = tracking_num.split(',')[0].strip()
            
            if po_id and tracking_num and tracking_num != 'nan':
                dispatch_list.append({
                    "productOrderId": po_id,
                    "deliveryMethod": "DELIVERY",
                    "deliveryCompanyCode": deliveryCompanyCode,
                    "trackingNumber": tracking_num
                })
        
        if not dispatch_list:
            return JSONResponse({"status": "error", "message": "유효한 송장 매핑 데이터가 없습니다."}, status_code=400)
            
        token = get_access_token()
        success_count = 0
        fail_list = []
        
        chunk_size = 100
        for i in range(0, len(dispatch_list), chunk_size):
            chunk = dispatch_list[i:i+chunk_size]
            result = dispatch_orders(token, chunk)
            
            results = result.get('data', {}).get('successProductOrderIds', [])
            success_count += len(results)
            
            fails = result.get('data', {}).get('failProductOrderInfos', [])
            for f in fails:
                fail_list.append(f)
                
        return {
            "status": "success", 
            "success_count": success_count,
            "fail_count": len(fail_list),
            "fails": fail_list,
            "total_processed": len(dispatch_list)
        }

    except Exception as e:
        return JSONResponse({"status": "error", "message": str(e)}, status_code=500)

from fastapi.responses import RedirectResponse

@app.get("/api/cafe24/login")
async def cafe24_login():
    url = cafe24_api.get_auth_url()
    return RedirectResponse(url)

@app.get("/api/cafe24/callback")
@app.get("/admin/oauth/callback")
async def cafe24_callback(code: str):
    home = f"{APP_ROOT_PATH}/" if APP_ROOT_PATH else "/"
    try:
        cafe24_api.request_new_token(code)
        return HTMLResponse(f"<script>alert('카페24 연동 성공!'); window.location.href='{home}';</script>")
    except Exception as e:
        return HTMLResponse(f"<script>alert('인증 실패: {e}'); window.location.href='{home}';</script>")

task_store = {}

@app.post("/api/cafe24/start_download")
async def start_cafe24_download(background_tasks: BackgroundTasks):
    task_id = str(uuid.uuid4())
    task_store[task_id] = {
        "status": "processing",
        "current": 0,
        "total": 0,
        "file_bytes": None,
        "filename": None,
        "error": None
    }
    
    def run_task(tid):
        try:
            total_count = cafe24_api.get_cafe24_orders_count(status="N20")
            task_store[tid]["total"] = total_count
            
            def progress_callback(current):
                task_store[tid]["current"] = current
                
            orders = cafe24_api.get_cafe24_orders(status="N20", progress_callback=progress_callback)
            if not orders:
                task_store[tid]["status"] = "error"
                task_store[tid]["error"] = "새로운 배송준비중 주문이 없습니다."
                return
            
            data = []
            now = datetime.datetime.now()
            numeric_date = int(now.strftime('%Y%m%d'))
            code_to_name = get_all_code_to_name_from_db()
            exchange_customers = []
            
            for idx, order in enumerate(orders):
                items = order.get('items', [])
                receivers = order.get('receivers', [])
                receiver = receivers[0] if receivers else {}
                
                has_exchange = False
                for item in items:
                    if item.get('exchange_request_date') or item.get('exchange_date') or str(item.get('status_code', '')).startswith('E'):
                        has_exchange = True
                        break
                        
                if has_exchange:
                    exchange_customers.append({
                        "name": receiver.get('name', ''),
                        "phone": receiver.get('cellphone') or receiver.get('phone', ''),
                        "order_no": order.get('order_id', '')
                    })
                    continue
                
                address_full = receiver.get('address_full', '')
                if not address_full:
                    address_full = f"{receiver.get('address1', '')} {receiver.get('address2', '')}".strip()
                full_address = address_full
                
                for item in items:
                    if not item.get('status_code', '').startswith('N'):
                        continue
                        
                    seller_code = item.get('custom_variant_code') or item.get('item_code') or item.get('custom_product_code') or item.get('product_code', '')
                    
                    final_quantity = int(item.get('quantity', 1))
                    if seller_code and '_' in seller_code:
                        parts = seller_code.rsplit('_', 1)
                        if len(parts) == 2 and parts[1].isdigit():
                            seller_code = parts[0]
                            final_quantity = final_quantity * int(parts[1])
                            
                    original_product_name = item.get('product_name', '')
                    product_name = code_to_name.get(seller_code, original_product_name)
                    original_quantity = item.get('quantity', 1)
                    
                    option_info = item.get('option_value', '')
                    if option_info:
                        order_list = f"{original_product_name}/{option_info}/{original_quantity}"
                    else:
                        order_list = f"{original_product_name}/{original_quantity}"
                        
                    row = {
                        '주문날짜': numeric_date,
                        '번호': 0,
                        '주문번호': item.get('order_item_code') or order.get('order_id', ''),
                        '수령인명': receiver.get('name', ''),
                        '상품코드': seller_code,
                        '상품명': product_name,
                        '수량': final_quantity,
                        '주소': full_address,
                        '우편번호': receiver.get('zipcode', ''),
                        '수령인 전화번호': receiver.get('phone', ''),
                        '수령인 휴대폰': receiver.get('cellphone', ''),
                        '배송시 요구사항': receiver.get('shipping_message', ''),
                        '송장번호': '',
                        '쇼핑몰명': '자사몰',
                        '비고': order.get('admin_additional_amount_name', ''),
                        '주문번호(쇼핑몰)': order.get('order_id', ''),
                        '주문목록': order_list
                    }
                    data.append(row)
                    
            weekdays = ['월요일', '화요일', '수요일', '목요일', '금요일', '토요일', '일요일']
            weekday_str = weekdays[now.weekday()]
            
            if now.hour >= 15:
                filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 0 미라네 자사몰 발주-3시.xlsx"
            else:
                filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 0 미라네 자사몰 발주-오전.xlsx"
                
            file_bytes, enc_name = generate_order_excel(data, numeric_date, filename, is_cafe24=True, return_bytes=True)
            
            task_store[tid]["current"] = task_store[tid]["total"]
            task_store[tid]["file_bytes"] = file_bytes
            task_store[tid]["filename"] = enc_name
            task_store[tid]["exchange_customers"] = exchange_customers
            task_store[tid]["status"] = "completed"
            
        except Exception as e:
            status_code = 500
            if "Authentication" in str(e) or "Please authenticate" in str(e) or "expired" in str(e):
                status_code = 401
            task_store[tid]["status"] = "error"
            task_store[tid]["error"] = str(e)
            task_store[tid]["status_code"] = status_code

    background_tasks.add_task(run_task, task_id)
    return {"status": "success", "task_id": task_id}

@app.get("/api/tasks/progress/{task_id}")
async def get_task_progress(task_id: str):
    if task_id not in task_store:
        return JSONResponse({"status": "error", "message": "작업을 찾을 수 없습니다."}, status_code=404)
        
    task_data = task_store[task_id]
    if task_data["status"] == "error":
        status_code = task_data.get("status_code", 500)
        return JSONResponse({"status": "error", "message": task_data["error"]}, status_code=status_code)
        
    return {
        "status": task_data["status"],
        "current": task_data["current"],
        "total": task_data["total"]
    }

@app.get("/api/tasks/download_file/{task_id}")
async def download_task_file(task_id: str):
    if task_id not in task_store or task_store[task_id]["status"] != "completed":
        return JSONResponse({"status": "error", "message": "파일이 아직 준비되지 않았거나 만료되었습니다."}, status_code=404)
        
    task_data = task_store[task_id]
    output = io.BytesIO(task_data["file_bytes"])
    enc_name = task_data["filename"]
    
    headers = {
        "Content-Disposition": f"attachment; filename*=UTF-8''{enc_name}"
    }
    
    # 메모리 정리 (1회 다운로드 후 삭제)
    del task_store[task_id]
    
    return StreamingResponse(
        output,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers=headers
    )


@app.post("/api/cafe24/upload_invoices")
async def cafe24_upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str = Form("0004")):
    try:
        content = await file.read()
        if file.filename.endswith('.xls'):
            try:
                df = pd.read_excel(io.BytesIO(content), engine='xlrd')
            except:
                df = pd.read_html(io.BytesIO(content))[0]
        else:
            df = pd.read_excel(io.BytesIO(content), engine='openpyxl')
            
        if '주문번호(쇼핑몰)' not in df.columns or '송장번호' not in df.columns:
            return JSONResponse({"status": "error", "message": "엑셀에 '주문번호(쇼핑몰)' 또는 '송장번호' 컬럼이 없습니다."}, status_code=400)
            
        df = df[df['송장번호'].notna() & (df['송장번호'] != '')]
        if df.empty:
            return JSONResponse({"status": "error", "message": "송장번호가 입력된 데이터가 없습니다."}, status_code=400)
            
        dispatch_list = []
        for index, row in df.iterrows():
            po_id = str(row['주문번호(쇼핑몰)']).replace('.0', '').strip()
            tracking_num = str(row['송장번호']).replace('.0', '').strip()
            if ',' in tracking_num:
                tracking_num = tracking_num.split(',')[0].strip()
            
            if po_id and tracking_num and tracking_num != 'nan':
                dispatch_list.append({
                    "order_id": po_id,
                    "tracking_no": tracking_num,
                    "shipping_company_code": deliveryCompanyCode
                })
                
        results = cafe24_api.update_cafe24_tracking(dispatch_list)
        return {
            "status": "success",
            "success_count": len(results["success"]),
            "fail_count": len(results["fail"]),
            "fails": results["fail"],
            "total_processed": len(dispatch_list)
        }
    except Exception as e:
        status_code = 500
        if "Authentication" in str(e) or "Please authenticate" in str(e) or "expired" in str(e):
            status_code = 401
        return JSONResponse({"status": "error", "message": str(e)}, status_code=status_code)

# ==========================================
# 반품 관리 API
# ==========================================

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("APP_PORT", "8002")))
