#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""웹진 전용 누락 상세수집 및 기사 생성 실행기.

기존 야간 목록수집기·naver_response_article_fetcher.py·야간 스케줄은 수정하지
않는다. 웹진 전용 페이지 API로 현재 매물번호를 검증하고, 기존 상세가 없는
매물만 --article-no로 보충 수집한 뒤 웹진 공개·비공개 상태까지 동기화한다.
"""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import time
from collections import deque
from datetime import datetime, time as clock_time
from pathlib import Path
from typing import Any


FILE_PATH = Path(__file__).resolve()
JOBS_DIR = FILE_PATH.parent
BASE_DIR = JOBS_DIR.parent if JOBS_DIR.name.lower() == "jobs" else JOBS_DIR
for candidate in (JOBS_DIR, BASE_DIR):
    if str(candidate) not in sys.path:
        sys.path.insert(0, str(candidate))

from db import get_conn  # type: ignore  # noqa: E402
from multi_webzine_current_articles_fast import collect_current_articles  # type: ignore  # noqa: E402


LOCK_NAME = "multi_webzine_detail_sync"
DEFAULT_START = os.getenv("MULTI_WEBZINE_DETAIL_START", "10:00")
DEFAULT_END = os.getenv("MULTI_WEBZINE_DETAIL_END", "18:00")
DEFAULT_PER_REALTOR = int(os.getenv("MULTI_WEBZINE_DETAIL_PER_REALTOR", "20"))
DEFAULT_MAX_TOTAL = int(os.getenv("MULTI_WEBZINE_DETAIL_MAX_TOTAL", "100"))
DEFAULT_SLEEP = float(os.getenv("MULTI_WEBZINE_DETAIL_SLEEP", "3"))
DEFAULT_SNAPSHOT_MAX_AGE_HOURS = int(os.getenv("MULTI_WEBZINE_SNAPSHOT_MAX_AGE_HOURS", "36"))


def snapshot_day_sql(alias: str = "") -> str:
    """18:05~다음날 09:55 야간작업을 한 작업일로 묶는다."""
    prefix = f"{alias}." if alias else ""
    return f"DATE(DATE_SUB({prefix}last_seen_at, INTERVAL 10 HOUR))"


def resolve_program(env_name: str, candidates: list[Path]) -> tuple[Path, list[Path]]:
    configured = os.getenv(env_name, "").strip()
    checked = [Path(configured)] if configured else candidates
    for candidate in checked:
        if candidate.is_file():
            return candidate.resolve(), checked
    return checked[0], checked


FETCHER, FETCHER_CANDIDATES = resolve_program(
    "MULTI_WEBZINE_DETAIL_FETCHER",
    [
        BASE_DIR / "services" / "multi_naver_response_article_fetcher.py",
        JOBS_DIR / "multi_naver_response_article_fetcher.py",
        BASE_DIR / "multi_naver_response_article_fetcher.py",
    ],
)
GENERATOR, GENERATOR_CANDIDATES = resolve_program(
    "MULTI_WEBZINE_ARTICLE_GENERATOR",
    [
        JOBS_DIR / "generate_multi_articles.py",
        BASE_DIR / "generate_multi_articles.py",
        BASE_DIR / "scripts" / "generate_multi_articles.py",
    ],
)


def parse_clock(value: str) -> clock_time:
    try:
        return datetime.strptime(value.strip(), "%H:%M").time()
    except ValueError as exc:
        raise argparse.ArgumentTypeError("시간은 HH:MM 형식이어야 합니다.") from exc


def inside_window(start: clock_time, end: clock_time) -> bool:
    now = datetime.now().time()
    return start <= now < end if start <= end else now >= start or now < end


def acquire_lock(conn: Any) -> bool:
    with conn.cursor() as cur:
        cur.execute("SELECT GET_LOCK(%s,0) AS acquired", (LOCK_NAME,))
        row = cur.fetchone() or {}
    return int(row.get("acquired") or 0) == 1


def release_lock(conn: Any) -> None:
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT RELEASE_LOCK(%s)", (LOCK_NAME,))
    except Exception:
        pass


def ensure_webzine_settings(conn: Any) -> None:
    """웹진 전용 발행 설정을 보장하고 기존 운영 웹진을 1회 보정한다."""
    with conn.cursor() as cur:
        cur.execute(
            """CREATE TABLE IF NOT EXISTS multi_webzine_realtor_settings (
                   realtor_id BIGINT NOT NULL PRIMARY KEY,
                   webzine_publish_enabled TINYINT(1) NOT NULL DEFAULT 1,
                   created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                   updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                   KEY idx_multi_webzine_publish (webzine_publish_enabled)
               ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"""
        )
        cur.execute(
            """INSERT IGNORE INTO multi_webzine_realtor_settings
                   (realtor_id,webzine_publish_enabled,created_at,updated_at)
               SELECT DISTINCT realtor_id,1,NOW(),NOW()
                 FROM multi_sites"""
        )
    conn.commit()


def server_publish_status(conn: Any) -> tuple[bool, int, str]:
    """서버 발행 작업이 실제 실행 중인지 확인한다.

    pending은 발행 완료 뒤에도 남는 기존 데이터가 있을 수 있어 차단 조건에서
    제외한다. 오래된 processing 행 때문에 영구 차단되지 않도록 시작 후 6시간
    이내이거나 시작시각이 없는 활성 행만 검사한다.
    """
    try:
        with conn.cursor() as cur:
            cur.execute("SHOW COLUMNS FROM blog_publish_queue")
            columns = {str(row.get("Field") or "") for row in (cur.fetchall() or [])}
        status_columns = [
            name for name in ("queue_status", "worker_status") if name in columns
        ]
        if "execution_target" not in columns or not status_columns:
            return True, 0, "required_status_columns_missing"

        active_values = "'processing','publishing','running','claimed'"
        status_sql = " OR ".join(
            f"LOWER(COALESCE({name},'')) IN ({active_values})"
            for name in status_columns
        )
        where = ["execution_target='server'", f"({status_sql})"]
        if "finished_at" in columns:
            where.append("finished_at IS NULL")
        if "started_at" in columns:
            where.append(
                "(started_at IS NULL OR started_at >= DATE_SUB(NOW(),INTERVAL 6 HOUR))"
            )
        with conn.cursor() as cur:
            cur.execute(
                "SELECT COUNT(*) AS active_count FROM blog_publish_queue WHERE "
                + " AND ".join(where)
            )
            row = cur.fetchone() or {}
        count = int(row.get("active_count") or 0)
        return count > 0, count, "active_server_publish" if count else "idle"
    except Exception as exc:
        # 상태 확인 자체가 실패하면 동시 실행 위험을 피하기 위해 보수적으로 중단한다.
        return True, 0, f"status_check_failed:{type(exc).__name__}:{exc}"


def active_realtors(conn: Any, realtor_id: int | None) -> list[dict[str, Any]]:
    sql = """
        SELECT r.id AS realtor_id,r.office_name,r.naver_realtor_id,MAX(s.id) AS site_id
          FROM multi_sites s
          INNER JOIN blog_realtors r ON r.id=s.realtor_id
         WHERE s.status='active'
           AND r.status='active'
           AND COALESCE(r.is_deleted,0)=0
           AND COALESCE(r.is_collect_enabled,0)=1
           AND EXISTS (
               SELECT 1
                 FROM multi_webzine_realtor_settings ws
                WHERE ws.realtor_id=r.id
                  AND COALESCE(ws.webzine_publish_enabled,0)=1
           )
    """
    params: list[Any] = []
    if realtor_id:
        sql += " AND r.id=%s"
        params.append(int(realtor_id))
    sql += " GROUP BY r.id,r.office_name,r.naver_realtor_id ORDER BY r.id"
    with conn.cursor() as cur:
        cur.execute(sql, params)
        return list(cur.fetchall() or [])


def latest_snapshot(conn: Any, realtor_id: int) -> dict[str, Any]:
    """야간수집이 갱신한 가장 최신 작업일과 현재 매물번호 수를 반환한다."""
    day_expr = snapshot_day_sql()
    with conn.cursor() as cur:
        cur.execute(
            f"""SELECT MAX({day_expr}) AS snapshot_day,MAX(last_seen_at) AS latest_seen_at
                  FROM blog_realtor_articles
                 WHERE realtor_id=%s AND last_seen_at IS NOT NULL""",
            (int(realtor_id),),
        )
        header = cur.fetchone() or {}
        snapshot_day = header.get("snapshot_day")
        if not snapshot_day:
            return {"snapshot_day": None, "latest_seen_at": None, "count": 0,
                    "sale": 0, "lease": 0, "rent": 0}
        cur.execute(
            f"""SELECT COUNT(DISTINCT article_no) AS article_count,
                       COUNT(DISTINCT CASE WHEN UPPER(COALESCE(trade_type,'')) IN ('A1','매매') THEN article_no END) AS sale_count,
                       COUNT(DISTINCT CASE WHEN UPPER(COALESCE(trade_type,'')) IN ('B1','전세') THEN article_no END) AS lease_count,
                       COUNT(DISTINCT CASE WHEN UPPER(COALESCE(trade_type,'')) IN ('B2','B3','월세','단기임대') THEN article_no END) AS rent_count
                  FROM blog_realtor_articles
                 WHERE realtor_id=%s
                   AND {day_expr}=%s
                   """,
            (int(realtor_id), snapshot_day),
        )
        counts = cur.fetchone() or {}
    return {
        "snapshot_day": snapshot_day,
        "latest_seen_at": header.get("latest_seen_at"),
        "count": int(counts.get("article_count") or 0),
        "sale": int(counts.get("sale_count") or 0),
        "lease": int(counts.get("lease_count") or 0),
        "rent": int(counts.get("rent_count") or 0),
    }


def latest_snapshot_articles(
    conn: Any, realtor_id: int, snapshot_day: Any
) -> list[dict[str, Any]]:
    """최신 야간 원본 스냅샷의 매물번호를 상태값과 무관하게 반환한다.

    이전의 잘못된 부분수집으로 removed/private 처리된 행도 최신 스냅샷에서
    실제 확인된 매물이면 복구 대상에 포함한다.
    """
    if not snapshot_day:
        return []
    day_expr = snapshot_day_sql()
    with conn.cursor() as cur:
        cur.execute(
            f"""SELECT article_no,trade_type,first_posted_at
                  FROM blog_realtor_articles
                 WHERE realtor_id=%s AND {day_expr}=%s
                   AND NULLIF(TRIM(article_no),'') IS NOT NULL
                 ORDER BY COALESCE(first_posted_at,created_at) DESC,article_no DESC""",
            (int(realtor_id), snapshot_day),
        )
        rows = list(cur.fetchall() or [])
    output: list[dict[str, Any]] = []
    seen: set[str] = set()
    for row in rows:
        article_no = str(row.get("article_no") or "").strip()
        if not article_no or article_no in seen:
            continue
        seen.add(article_no)
        registered_at = row.get("first_posted_at")
        output.append(
            {
                "article_no": article_no,
                "trade_type": str(row.get("trade_type") or ""),
                "registered_at": registered_at,
                "source_rank": len(output),
            }
        )
    return output


def validate_snapshot(snapshot: dict[str, Any], max_age_hours: int) -> tuple[bool, str]:
    if not snapshot.get("snapshot_day") or not snapshot.get("latest_seen_at"):
        return False, "last_seen_snapshot_not_found"
    if int(snapshot.get("count") or 0) <= 0:
        return False, "snapshot_is_empty"
    latest_seen = snapshot["latest_seen_at"]
    if isinstance(latest_seen, str):
        try:
            latest_seen = datetime.fromisoformat(latest_seen)
        except ValueError:
            return False, "invalid_latest_seen_at"
    age_hours = (datetime.now() - latest_seen).total_seconds() / 3600
    if age_hours < -1 or age_hours > max(1, int(max_age_hours)):
        return False, f"stale_snapshot_age_hours={age_hours:.1f}"
    return True, "ok"


def pending_for_realtor(
    conn: Any, realtor_id: int, current_articles: list[dict[str, Any]], limit: int
) -> list[dict[str, Any]]:
    """상세가 없거나 웹진 기사만 누락된 현재 매물을 최신순으로 가져온다."""
    current_article_nos = {
        str(item.get("article_no") or "").strip() for item in current_articles
        if str(item.get("article_no") or "").strip()
    }
    if not current_article_nos:
        return []
    marks = ",".join(["%s"] * len(current_article_nos))
    sql = f"""
        SELECT id,realtor_id,article_no,COALESCE(detail_collected,0) AS detail_collected,
               COALESCE(first_posted_at,detail_collected_at,created_at) AS registered_at,
               CASE WHEN NOT EXISTS (
                    SELECT 1 FROM multi_articles published_ma
                     WHERE published_ma.realtor_id=blog_realtor_articles.realtor_id
                       AND BINARY published_ma.source_article_no=BINARY blog_realtor_articles.article_no
                       AND published_ma.status='published'
               ) THEN 1 ELSE 0 END AS article_missing,
               CASE WHEN EXISTS (
                    SELECT 1 FROM multi_articles quality_ma
                     WHERE quality_ma.realtor_id=blog_realtor_articles.realtor_id
                       AND BINARY quality_ma.source_article_no=BINARY blog_realtor_articles.article_no
                       AND quality_ma.status IN ('published','archived')
                       AND (
                            quality_ma.category_code IN ('기타','etc','other','')
                            OR CHAR_LENGTH(TRIM(COALESCE(quality_ma.title,'')))<8
                            OR CHAR_LENGTH(TRIM(COALESCE(quality_ma.body_html,'')))<300
                            OR NOT EXISTS (
                                SELECT 1 FROM multi_article_images quality_img
                                 WHERE quality_img.article_id=quality_ma.id
                            )
                       )
               ) THEN 1 ELSE 0 END AS quality_refresh
         FROM blog_realtor_articles
         WHERE realtor_id=%s
           AND article_no IN ({marks})
           AND COALESCE(article_status,'active') NOT IN ('removed','closed')
           AND COALESCE(visibility_status,'public')='public'
           AND (
                COALESCE(detail_collected,0)=0
                OR NOT EXISTS (
                    SELECT 1 FROM multi_articles ma
                     WHERE ma.realtor_id=blog_realtor_articles.realtor_id
                       AND BINARY ma.source_article_no=BINARY blog_realtor_articles.article_no
                       AND ma.status='published'
                )
                OR EXISTS (
                    SELECT 1 FROM multi_articles weak_ma
                     WHERE weak_ma.realtor_id=blog_realtor_articles.realtor_id
                       AND BINARY weak_ma.source_article_no=BINARY blog_realtor_articles.article_no
                       AND weak_ma.status IN ('published','archived')
                       AND (
                            weak_ma.category_code IN ('기타','etc','other','')
                            OR CHAR_LENGTH(TRIM(COALESCE(weak_ma.title,'')))<8
                            OR CHAR_LENGTH(TRIM(COALESCE(weak_ma.body_html,'')))<300
                            OR NOT EXISTS (
                                SELECT 1 FROM multi_article_images weak_img
                                 WHERE weak_img.article_id=weak_ma.id
                            )
                       )
                )
           )
    """
    with conn.cursor() as cur:
        params: list[Any] = [int(realtor_id), *sorted(current_article_nos)]
        cur.execute(sql, params)
        rows = list(cur.fetchall() or [])
    rank_map = {
        str(item.get("article_no")): int(item.get("source_rank") or 0)
        for item in current_articles
    }
    date_map = {
        str(item.get("article_no")): parse_naver_ymd(item.get("registered_at"))
        for item in current_articles
    }
    minimum = datetime(1970, 1, 1)
    for row in rows:
        article_no = str(row.get("article_no"))
        row["naver_list_rank"] = rank_map.get(article_no, 999999) + 1
        row["naver_list_date"] = date_map.get(article_no)
    # 공개 기사가 없는 매물을 최우선으로 처리한다. 그다음 상세 미수집,
    # 마지막에 이미 공개된 기사의 품질보강을 처리하여 품질보강 대상이
    # 매 회차 상위 20건을 점유하면서 누락 기사가 밀리는 현상을 방지한다.
    rows.sort(
        key=lambda row: (
            int(row.get("article_missing") or 0),
            1 if int(row.get("detail_collected") or 0) != 1 else 0,
            1 if date_map.get(str(row.get("article_no"))) else 0,
            date_map.get(str(row.get("article_no"))) or minimum,
            -rank_map.get(str(row.get("article_no")), 999999),
        ),
        reverse=True,
    )
    return rows[:max(1, int(limit))]


def reconciliation_counts(conn: Any, realtor_id: int, snapshot: dict[str, Any]) -> dict[str, int]:
    """원천 현재 매물과 공개 웹진 기사 수를 같은 article_no 기준으로 비교한다."""
    with conn.cursor() as cur:
        cur.execute(
            """SELECT COUNT(DISTINCT ma.source_article_no) AS webzine_published
                 FROM multi_articles ma
                 INNER JOIN multi_sites s ON s.id=ma.site_id AND s.status='active'
                WHERE ma.realtor_id=%s AND ma.status='published'""",
            (int(realtor_id),),
        )
        webzine = cur.fetchone() or {}
    source_current = int(snapshot.get("count") or 0)
    webzine_published = int(webzine.get("webzine_published") or 0)
    return {
        "source_current": source_current,
        "sale": int(snapshot.get("sale") or 0),
        "lease": int(snapshot.get("lease") or 0),
        "rent": int(snapshot.get("rent") or 0),
        "webzine_published": webzine_published,
        "missing": max(0, source_current - webzine_published),
    }


def destructive_sync_baseline(conn: Any, realtor_id: int) -> int:
    """부분 목록을 전체 목록으로 오인하지 않기 위한 기존 활성 건수 기준값."""
    with conn.cursor() as cur:
        cur.execute(
            """SELECT
                   (SELECT COUNT(DISTINCT article_no)
                      FROM blog_realtor_articles
                     WHERE realtor_id=%s
                       AND COALESCE(article_status,'active') NOT IN ('removed','closed')) AS source_count,
                   (SELECT COUNT(DISTINCT source_article_no)
                      FROM multi_articles
                     WHERE realtor_id=%s AND status IN ('published','archived')) AS article_count""",
            (int(realtor_id), int(realtor_id)),
        )
        row = cur.fetchone() or {}
    return max(int(row.get("source_count") or 0), int(row.get("article_count") or 0))


def print_reconciliation(
    conn: Any, realtor_id: int, snapshot: dict[str, Any], stage: str
) -> None:
    counts = reconciliation_counts(conn, realtor_id, snapshot)
    print(
        f"[MULTI WEBZINE RECONCILE {stage}] realtor_id={realtor_id} "
        f"snapshot_day={snapshot.get('snapshot_day')} "
        f"source_current={counts['source_current']} sale={counts['sale']} "
        f"lease={counts['lease']} rent={counts['rent']} "
        f"webzine_published={counts['webzine_published']} missing={counts['missing']}"
    )


def visibility_plan(
    conn: Any, realtor_id: int, current_article_nos: set[str]
) -> dict[str, list[str]]:
    """현재 목록을 기준으로 웹진 완전삭제·기존 archived 복구 대상을 계산한다."""
    with conn.cursor() as cur:
        cur.execute(
            """SELECT ma.source_article_no,ma.status
                  FROM multi_articles ma
                 WHERE ma.realtor_id=%s AND ma.status IN ('published','archived')
                   AND NULLIF(TRIM(ma.source_article_no),'') IS NOT NULL
            """,
            (int(realtor_id),),
        )
        rows = list(cur.fetchall() or [])
    delete = [
        str(row["source_article_no"]) for row in rows
        if str(row["source_article_no"]) not in current_article_nos
    ]
    restore = [
        str(row["source_article_no"]) for row in rows
        if row.get("status") == "archived"
        and str(row["source_article_no"]) in current_article_nos
    ]
    return {"delete": delete, "restore": restore}


def ensure_source_articles(
    conn: Any, realtor_id: int, articles: list[dict[str, Any]]
) -> int:
    """웹진 API에서 새로 확인된 번호만 원천 캐시에 추가한다.

    야간 Snapshot 판단에 섞이지 않도록 last_seen_at은 갱신하지 않는다.
    """
    inserted = 0
    with conn.cursor() as cur:
        for item in articles:
            article_no = str(item.get("article_no") or "").strip()
            if not article_no:
                continue
            inserted += cur.execute(
                """INSERT INTO blog_realtor_articles
                    (realtor_id,article_no,trade_type,collect_status,article_status,
                     visibility_status,last_seen_at,first_posted_at,created_at,updated_at)
                    VALUES (%s,%s,%s,'pending','active','public',NULL,%s,NOW(),NOW())
                    ON DUPLICATE KEY UPDATE
                      article_status='active',visibility_status='public',
                      first_posted_at=COALESCE(VALUES(first_posted_at),first_posted_at)""",
                (
                    int(realtor_id), article_no, str(item.get("trade_type") or ""),
                    parse_naver_ymd(item.get("registered_at")),
                ),
            )
    conn.commit()
    return int(inserted)


def mark_missing_source_articles_removed(
    conn: Any, realtor_id: int, current_article_nos: set[str]
) -> int:
    """전체수집에서 사라진 누적 원천 매물을 현재 매물 대상에서 제외한다.

    원천 행은 다른 작업의 이력 확인을 위해 보존하되, 웹진 현재매물 집계와
    전체 기사 생성기가 다시 선택하지 않도록 removed/private로 전환한다.
    이 함수는 현재 목록 전체수집이 성공한 뒤에만 호출한다.
    """
    params: list[Any] = [int(realtor_id)]
    sql = """
        UPDATE blog_realtor_articles
           SET article_status='removed',visibility_status='private',updated_at=NOW()
         WHERE realtor_id=%s
           AND COALESCE(article_status,'active') NOT IN ('removed','closed')
    """
    article_nos = sorted(str(value).strip() for value in current_article_nos if str(value).strip())
    if article_nos:
        sql += " AND article_no NOT IN (" + ",".join(["%s"] * len(article_nos)) + ")"
        params.extend(article_nos)
    with conn.cursor() as cur:
        affected = cur.execute(sql, params)
    conn.commit()
    return int(affected or 0)


def apply_visibility_plan(conn: Any, realtor_id: int, plan: dict[str, list[str]]) -> tuple[int, int]:
    deleted = restored = 0
    with conn.cursor() as cur:
        cur.execute("SHOW TABLES")
        existing_tables = {
            str(next(iter(row.values()))) for row in (cur.fetchall() or []) if row
        }
        for article_no in plan["delete"]:
            cur.execute(
                """SELECT id FROM multi_articles
                    WHERE realtor_id=%s AND BINARY source_article_no=BINARY %s""",
                (int(realtor_id), article_no),
            )
            article_ids = [int(row["id"]) for row in (cur.fetchall() or [])]
            for article_id in article_ids:
                # YouTube에 올라간 숏츠가 있으면 기사/작업을 먼저 지우지 않는다.
                # 삭제 큐를 남기고 웹진에서는 즉시 숨긴 뒤, YouTube 삭제(또는 비공개)
                # 완료 상태(cancelled)가 된 다음 회차에 실제 DB 삭제를 진행한다.
                youtube_busy = False
                if "multi_shorts_jobs" in existing_tables and "multi_shorts_platform_posts" in existing_tables:
                    cur.execute(
                        """SELECT p.id,p.upload_status,j.id AS job_id
                             FROM multi_shorts_jobs j
                             INNER JOIN multi_shorts_platform_posts p
                                     ON p.shorts_job_id=j.id AND p.platform='youtube'
                            WHERE j.article_id=%s
                              AND COALESCE(p.remote_media_id,'')<>''""",
                        (article_id,),
                    )
                    for video in list(cur.fetchall() or []):
                        upload_status = str(video.get("upload_status") or "")
                        if upload_status in ("uploaded", "delete_failed"):
                            cur.execute(
                                """UPDATE multi_shorts_platform_posts
                                      SET upload_status='delete_pending',retry_count=0,
                                          upload_error=NULL,updated_at=NOW()
                                    WHERE id=%s""",
                                (int(video["id"]),),
                            )
                            cur.execute(
                                """INSERT INTO multi_shorts_job_events
                                    (shorts_job_id,event_type,from_status,to_status,message,created_at)
                                    VALUES (%s,'naver_missing_delete_requested',%s,'delete_pending',
                                            '네이버 현재매물 제외 감지: YouTube 삭제 요청',NOW())""",
                                (int(video["job_id"]), upload_status),
                            )
                            youtube_busy = True
                        elif upload_status in ("delete_pending", "deleting", "delete_retry_wait"):
                            youtube_busy = True
                    if youtube_busy:
                        cur.execute(
                            "UPDATE multi_articles SET status='archived',updated_at=NOW() WHERE id=%s",
                            (article_id,),
                        )
                        print(
                            f"[MULTI WEBZINE YOUTUBE DELETE QUEUED] "
                            f"realtor_id={realtor_id} article_id={article_id} article_no={article_no}"
                        )
                        continue
                for table_name in (
                    "multi_shorts_jobs", "multi_search_submission_jobs",
                    "multi_article_nearby_places", "multi_article_locations",
                    "multi_article_tag_map", "multi_article_images",
                ):
                    if table_name in existing_tables:
                        cur.execute(f"DELETE FROM {table_name} WHERE article_id=%s", (article_id,))
                deleted += cur.execute("DELETE FROM multi_articles WHERE id=%s", (article_id,))
        for article_no in plan["restore"]:
            restored += cur.execute(
                """UPDATE multi_articles SET status='published',updated_at=NOW()
                    WHERE realtor_id=%s AND BINARY source_article_no=BINARY %s
                      AND status='archived'""",
                (int(realtor_id), article_no),
            )
        # 수동 고정 헤드라인이 살아 있으면 유지하고, 없을 때만 최신 기사로 재선정한다.
        manual_headline_id = None
        try:
            cur.execute("SELECT headline_mode,manual_headline_article_id FROM multi_sites WHERE realtor_id=%s AND status='active' LIMIT 1", (int(realtor_id),))
            headline_setting = cur.fetchone() or {}
            if headline_setting.get("headline_mode") == "manual":
                manual_headline_id = headline_setting.get("manual_headline_article_id")
                if manual_headline_id:
                    cur.execute("SELECT id FROM multi_articles WHERE id=%s AND realtor_id=%s AND status='published'", (int(manual_headline_id), int(realtor_id)))
                    if not cur.fetchone():
                        manual_headline_id = None
        except Exception:
            manual_headline_id = None
        cur.execute(
            """UPDATE multi_articles SET is_headline=0
                WHERE realtor_id=%s""",
            (int(realtor_id),),
        )
        if manual_headline_id:
            cur.execute("UPDATE multi_articles SET is_headline=1 WHERE id=%s", (int(manual_headline_id),))
        else:
            cur.execute(
                """UPDATE multi_articles
                  SET is_headline=1
                WHERE id=(
                    SELECT picked.id FROM (
                        SELECT id FROM multi_articles
                         WHERE realtor_id=%s AND status='published'
                           AND COALESCE(headline_eligible,0)=1
                         ORDER BY published_at DESC,id DESC LIMIT 1
                    ) picked
                )""",
                (int(realtor_id),),
            )
    conn.commit()
    return int(deleted), int(restored)


def round_robin(groups: list[list[dict[str, Any]]], max_total: int) -> list[dict[str, Any]]:
    queues = deque(deque(group) for group in groups if group)
    result: list[dict[str, Any]] = []
    while queues and len(result) < max_total:
        queue = queues.popleft()
        result.append(queue.popleft())
        if queue:
            queues.append(queue)
    return result


def run_command(command: list[str], label: str) -> int:
    print(f"[{label} START] {' '.join(command)}", flush=True)
    completed = subprocess.run(command, cwd=str(BASE_DIR), check=False)
    print(f"[{label} DONE] exit={completed.returncode}", flush=True)
    return int(completed.returncode)


def detail_completed(conn: Any, realtor_id: int, article_no: str) -> bool:
    with conn.cursor() as cur:
        cur.execute(
            """SELECT detail_collected FROM blog_realtor_articles
                WHERE realtor_id=%s AND BINARY article_no=BINARY %s LIMIT 1""",
            (int(realtor_id), str(article_no)),
        )
        row = cur.fetchone() or {}
    return int(row.get("detail_collected") or 0) == 1


def parse_naver_ymd(value: Any) -> datetime | None:
    """네이버 API의 YYYYMMDD/날짜 문자열을 자정 시각으로 변환한다."""
    text = str(value or "").strip()
    for fmt in ("%Y%m%d", "%Y-%m-%d", "%Y.%m.%d"):
        try:
            return datetime.strptime(text[:10] if fmt != "%Y%m%d" else text[:8], fmt)
        except ValueError:
            continue
    return None


def naver_registered_at(raw_json: Any) -> tuple[datetime | None, str]:
    """네이버 화면의 확인·등록일을 원본 상세 응답에서 찾는다."""
    try:
        payload = json.loads(raw_json) if isinstance(raw_json, str) else raw_json
    except (TypeError, ValueError):
        return None, "invalid_raw_json"
    if not isinstance(payload, dict):
        return None, "invalid_raw_json"
    sections = [
        payload.get("articleDetail"),
        payload.get("articleAddition"),
        payload,
    ]
    # 네이버 매물 화면의 확인일을 우선 사용하고, 응답 버전에 따라
    # 노출 시작일만 있는 경우 이를 보조값으로 사용한다.
    for key in ("articleConfirmYMD", "exposeStartYMD"):
        for section in sections:
            if isinstance(section, dict):
                parsed = parse_naver_ymd(section.get(key))
                if parsed:
                    return parsed, key
    return None, "date_not_found"


def sync_one_naver_date(conn: Any, realtor_id: int, article_no: str) -> tuple[bool, str, datetime | None]:
    """원본 JSON의 네이버 등록일을 원천 매물과 기존 웹진 기사에 동기화한다."""
    with conn.cursor() as cur:
        cur.execute(
            """SELECT id,raw_json FROM blog_realtor_articles
                WHERE realtor_id=%s AND BINARY article_no=BINARY %s LIMIT 1""",
            (int(realtor_id), str(article_no)),
        )
        row = cur.fetchone() or {}
    registered_at, source_key = naver_registered_at(row.get("raw_json"))
    if not registered_at:
        return False, source_key, None
    with conn.cursor() as cur:
        cur.execute(
            """UPDATE blog_realtor_articles
                  SET first_posted_at=%s,updated_at=updated_at
                WHERE id=%s""",
            (registered_at, row["id"]),
        )
        cur.execute(
            """UPDATE multi_articles
                  SET published_at=%s
                WHERE realtor_id=%s AND BINARY source_article_no=BINARY %s""",
            (registered_at, int(realtor_id), str(article_no)),
        )
    conn.commit()
    return True, source_key, registered_at


def sync_existing_naver_dates(conn: Any, realtor_id: int | None) -> tuple[int, int]:
    """목록에서 날짜를 얻지 못한 상세수집 매물만 등록일을 보충한다.

    현재 목록의 확인일이 이미 저장된 행을 과거 상세 응답 날짜로 되돌리지 않는다.
    """
    sql = """
        SELECT DISTINCT a.realtor_id,a.article_no
          FROM blog_realtor_articles a
          INNER JOIN multi_sites s ON s.realtor_id=a.realtor_id AND s.status='active'
         WHERE COALESCE(a.detail_collected,0)=1
           AND a.first_posted_at IS NULL
           AND COALESCE(a.article_status,'active') NOT IN ('removed','closed')
    """
    params: list[Any] = []
    if realtor_id:
        sql += " AND a.realtor_id=%s"
        params.append(int(realtor_id))
    synced = missing = 0
    with conn.cursor() as cur:
        cur.execute(sql, params)
        rows = list(cur.fetchall() or [])
    for row in rows:
        ok, _, _ = sync_one_naver_date(conn, int(row["realtor_id"]), str(row["article_no"]))
        if ok:
            synced += 1
        else:
            missing += 1
    return synced, missing


def detail_error(conn: Any, realtor_id: int, article_no: str) -> str:
    with conn.cursor() as cur:
        cur.execute(
            """SELECT collect_status,detail_error FROM blog_realtor_articles
                WHERE realtor_id=%s AND BINARY article_no=BINARY %s LIMIT 1""",
            (int(realtor_id), str(article_no)),
        )
        row = cur.fetchone() or {}
    return str(row.get("detail_error") or row.get("collect_status") or "unknown")[:500]


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--realtor-id", type=int)
    parser.add_argument("--limit-per-realtor", type=int, default=DEFAULT_PER_REALTOR)
    parser.add_argument("--max-total", type=int, default=DEFAULT_MAX_TOTAL)
    parser.add_argument("--sleep", type=float, default=DEFAULT_SLEEP)
    parser.add_argument("--start-time", type=parse_clock, default=parse_clock(DEFAULT_START))
    parser.add_argument("--end-time", type=parse_clock, default=parse_clock(DEFAULT_END))
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--ignore-window", action="store_true")
    parser.add_argument(
        "--ignore-server-publish-busy", action="store_true",
        help="서버 발행 실행 여부 검사를 생략(수동 점검용)",
    )
    parser.add_argument("--skip-webzine-generate", action="store_true")
    parser.add_argument(
        "--skip-existing-date-sync", action="store_true",
        help="이미 생성된 웹진 기사의 네이버 등록일 일괄 보정을 생략",
    )
    parser.add_argument(
        "--skip-visibility-sync", action="store_true",
        help="현재 네이버 목록 기준 웹진 삭제·복구를 생략",
    )
    parser.add_argument(
        "--snapshot-max-age-hours", type=int,
        default=DEFAULT_SNAPSHOT_MAX_AGE_HOURS,
        help="비공개 판단에 허용할 최신 last_seen_at 최대 경과시간",
    )
    parser.add_argument(
        "--current-max-pages", type=int, default=30,
        help="웹진 전용 현재 매물 API의 거래유형별 최대 페이지",
    )
    args = parser.parse_args()

    if not args.ignore_window and not inside_window(args.start_time, args.end_time):
        print(
            f"[MULTI WEBZINE DETAIL SKIP] outside_window "
            f"allowed={args.start_time.strftime('%H:%M')}-{args.end_time.strftime('%H:%M')}"
        )
        return
    # dry-run은 DB 대상만 점검하므로 외부 프로그램 위치가 아직 달라도 허용한다.
    if not args.dry_run and not FETCHER.is_file():
        checked = ", ".join(str(path) for path in FETCHER_CANDIDATES)
        raise RuntimeError(
            "기존 상세수집기를 찾을 수 없습니다. 확인 위치: " + checked
            + " / 다른 위치이면 MULTI_WEBZINE_DETAIL_FETCHER에 절대경로를 지정해 주세요."
        )
    if not args.dry_run and not args.skip_webzine_generate and not GENERATOR.is_file():
        checked = ", ".join(str(path) for path in GENERATOR_CANDIDATES)
        raise RuntimeError(
            "웹진 기사 생성기를 찾을 수 없습니다. 확인 위치: " + checked
            + " / 다른 위치이면 MULTI_WEBZINE_ARTICLE_GENERATOR에 절대경로를 지정해 주세요."
        )

    conn = get_conn()
    locked = False
    try:
        locked = acquire_lock(conn)
        if not locked:
            print("[MULTI WEBZINE DETAIL SKIP] another_webzine_sync_running")
            return

        ensure_webzine_settings(conn)

        if not args.ignore_server_publish_busy:
            busy, active_count, reason = server_publish_status(conn)
            if busy:
                print(
                    f"[MULTI WEBZINE DETAIL SKIP] server_publish_busy "
                    f"count={active_count} reason={reason}"
                )
                return
            print("[MULTI WEBZINE SERVER PUBLISH CHECK] busy=0")

        realtors = active_realtors(conn, args.realtor_id)
        if args.realtor_id and not realtors:
            raise RuntimeError(f"활성 웹진 realtor_id={args.realtor_id}를 찾을 수 없습니다.")
        snapshots: dict[int, dict[str, Any]] = {}
        current_sets: dict[int, set[str]] = {}
        current_articles: dict[int, list[dict[str, Any]]] = {}
        visibility_plans: dict[int, dict[str, list[str]]] = {}
        destructive_sync_allowed: dict[int, bool] = {}
        for realtor in realtors:
            current_realtor_id = int(realtor["realtor_id"])
            naver_realtor_id = str(realtor.get("naver_realtor_id") or "").strip()
            if not naver_realtor_id:
                raise RuntimeError(
                    f"realtor_id={current_realtor_id} naver_realtor_id가 없습니다."
                )
            source_snapshot = latest_snapshot(conn, current_realtor_id)
            snapshot_valid, snapshot_reason = validate_snapshot(
                source_snapshot, args.snapshot_max_age_hours
            )
            snapshot_items = latest_snapshot_articles(
                conn, current_realtor_id, source_snapshot.get("snapshot_day")
            ) if snapshot_valid else []
            collected = collect_current_articles(
                naver_realtor_id, max_pages=args.current_max_pages
            )
            collected_items = list(collected.get("articles") or [])
            if not collected.get("ok"):
                print(
                    f"[MULTI WEBZINE CURRENT LIST WARNING] realtor_id={current_realtor_id} "
                    f"errors={','.join(collected.get('errors') or ['unknown'])} "
                    f"fallback={'latest_snapshot' if snapshot_valid else 'none'}",
                    flush=True,
                )
                if not snapshot_valid:
                    raise RuntimeError(
                        f"realtor_id={current_realtor_id} 웹진 현재 매물 전체수집 실패 및 "
                        f"사용 가능한 원본 스냅샷 없음: {snapshot_reason}"
                    )

            # 최신 원본 스냅샷을 삭제 판단의 기준으로 사용한다. 화면 수집에서
            # 새로 확인된 매물은 합치되, 부분수집 때문에 스냅샷 매물을 버리지 않는다.
            merged_map: dict[str, dict[str, Any]] = {}
            for item in snapshot_items:
                article_no = str(item.get("article_no") or "").strip()
                if article_no:
                    merged_map[article_no] = dict(item)
            for item in collected_items:
                article_no = str(item.get("article_no") or "").strip()
                if not article_no:
                    continue
                if article_no in merged_map:
                    merged_map[article_no].update(
                        {key: value for key, value in item.items() if value not in (None, "")}
                    )
                else:
                    merged_map[article_no] = dict(item)
            merged_articles = list(merged_map.values())
            for rank, item in enumerate(merged_articles):
                item["source_rank"] = rank

            snapshot = source_snapshot if snapshot_valid else {
                "snapshot_day": "webzine_api_unverified",
                "latest_seen_at": datetime.now(),
                "count": len(merged_articles),
                "sale": int(collected.get("sale") or 0),
                "lease": int(collected.get("lease") or 0),
                "rent": int(collected.get("rent") or 0),
            }
            snapshots[current_realtor_id] = snapshot
            current_set = set(merged_map)
            current_sets[current_realtor_id] = current_set
            current_articles[current_realtor_id] = merged_articles
            baseline_count = destructive_sync_baseline(conn, current_realtor_id)
            complete_count = len(current_set)
            collector_complete = bool(collected.get("complete", collected.get("ok")))
            snapshot_count = int(source_snapshot.get("count") or 0)
            destructive_allowed = (
                snapshot_valid
                and snapshot_count > 0
                and len(snapshot_items) == snapshot_count
                and current_set.issuperset(
                    str(item.get("article_no") or "") for item in snapshot_items
                )
            )
            destructive_sync_allowed[current_realtor_id] = destructive_allowed
            if not destructive_allowed:
                print(
                    f"[MULTI WEBZINE DELETE GUARD] realtor_id={current_realtor_id} "
                    f"collector_complete={collector_complete} reported={complete_count} "
                    f"collected={len(current_set)} snapshot={snapshot_count} "
                    f"snapshot_valid={snapshot_valid} reason={snapshot_reason} baseline={baseline_count} "
                    "action=skip_remove_and_delete",
                    flush=True,
                )
            print(
                f"[MULTI WEBZINE SNAPSHOT] realtor_id={current_realtor_id} "
                f"source=webzine_api count={snapshot.get('count')} "
                f"sale={snapshot.get('sale')} lease={snapshot.get('lease')} "
                f"rent={snapshot.get('rent')} status=ok"
            )
            print_reconciliation(conn, current_realtor_id, snapshot, "BEFORE")
            plan = (
                visibility_plan(conn, current_realtor_id, current_set)
                if destructive_allowed
                else {"delete": [], "restore": []}
            )
            visibility_plans[current_realtor_id] = plan
            print(
                f"[MULTI WEBZINE VISIBILITY PLAN] realtor_id={current_realtor_id} "
                f"delete={len(plan['delete'])} restore={len(plan['restore'])}"
            )
            for article_no in plan["delete"]:
                print(
                    f"[MULTI WEBZINE DELETE TARGET] realtor_id={current_realtor_id} "
                    f"article_no={article_no}"
                )
            for article_no in plan["restore"]:
                print(
                    f"[MULTI WEBZINE RESTORE TARGET] realtor_id={current_realtor_id} "
                    f"article_no={article_no}"
                )
        if not args.dry_run:
            for realtor in realtors:
                current_realtor_id = int(realtor["realtor_id"])
                removed_source = 0
                if destructive_sync_allowed[current_realtor_id]:
                    removed_source = mark_missing_source_articles_removed(
                        conn,
                        current_realtor_id,
                        current_sets[current_realtor_id],
                    )
                print(
                    f"[MULTI WEBZINE SOURCE REMOVE] realtor_id={current_realtor_id} "
                    f"affected={removed_source}"
                )
                affected = ensure_source_articles(
                    conn, current_realtor_id, current_articles[current_realtor_id]
                )
                print(
                    f"[MULTI WEBZINE SOURCE UPSERT] realtor_id={current_realtor_id} "
                    f"affected={affected}"
                )
                if args.skip_visibility_sync:
                    continue
                deleted, restored = apply_visibility_plan(
                    conn, current_realtor_id, visibility_plans[current_realtor_id]
                )
                print(
                    f"[MULTI WEBZINE VISIBILITY DONE] realtor_id={current_realtor_id} "
                    f"deleted={deleted} restored={restored}"
                )
        if not args.dry_run and not args.skip_existing_date_sync:
            date_synced, date_missing = sync_existing_naver_dates(conn, args.realtor_id)
            print(
                f"[MULTI WEBZINE NAVER DATE SYNC] synced={date_synced} "
                f"missing={date_missing}"
            )
        groups = [
            pending_for_realtor(
                conn,
                int(row["realtor_id"]),
                current_articles[int(row["realtor_id"])],
                max(1, min(100, int(args.limit_per_realtor))),
            )
            for row in realtors
        ]
        # max_total=0이면 전체 중개사에서 선별된 최대 20건씩을 모두 처리한다.
        # 하루 3회 실행 시 중개사별 20 + 20 + 20 순서로 상세수집·기사화된다.
        max_total = (
            sum(len(group) for group in groups)
            if int(args.max_total) <= 0
            else max(1, int(args.max_total))
        )
        targets = round_robin(groups, max_total)
        print(
            f"[MULTI WEBZINE DETAIL PLAN] realtors={len(realtors)} "
            f"targets={len(targets)} per_realtor={args.limit_per_realtor} "
            f"max_total={'per_realtor_all' if int(args.max_total) <= 0 else args.max_total}"
        )
        for index, row in enumerate(targets, 1):
            print(
                f"[MULTI WEBZINE DETAIL TARGET] order={index} "
                f"realtor_id={row['realtor_id']} article_no={row['article_no']} "
                f"article_missing={int(row.get('article_missing') or 0)} "
                f"detail_collected={int(row.get('detail_collected') or 0)} "
                f"quality_refresh={int(row.get('quality_refresh') or 0)} "
                f"naver_list_rank={row.get('naver_list_rank')} "
                f"naver_list_date={row.get('naver_list_date') or '-'} "
                f"db_reference_at={row.get('registered_at')}"
            )
        if args.dry_run:
            return

        # 이후 별도 프로세스가 저장한 상세수집 결과를 같은 연결에서 즉시 읽도록
        # 대상 조회 시점의 읽기 트랜잭션을 끝낸다.
        conn.commit()

        detail_success = detail_failed = generated = generation_failed = 0
        failed_items: list[tuple[int, str, str]] = []
        target_batches: dict[int, list[dict[str, Any]]] = {}
        for row in targets:
            target_batches.setdefault(int(row["realtor_id"]), []).append(row)

        for batch_index, (realtor_id, batch_rows) in enumerate(target_batches.items(), 1):
            if not args.ignore_window and not inside_window(args.start_time, args.end_time):
                print("[MULTI WEBZINE DETAIL STOP] end_time_reached")
                break
            if not args.ignore_server_publish_busy:
                busy, active_count, reason = server_publish_status(conn)
                if busy:
                    print(
                        f"[MULTI WEBZINE DETAIL STOP] server_publish_started "
                        f"count={active_count} reason={reason}"
                    )
                    break

            fetch_rows = [
                row for row in batch_rows
                if int(row.get("detail_collected") or 0) != 1
                or int(row.get("quality_refresh") or 0) == 1
            ]
            fetch_exit = 0
            for row in batch_rows:
                if int(row.get("detail_collected") or 0) == 1 and int(row.get("quality_refresh") or 0) != 1:
                    print(
                        f"[MULTI WEBZINE DETAIL REUSE] realtor_id={realtor_id} "
                        f"article_no={row['article_no']}"
                    )
            if fetch_rows:
                article_nos = ",".join(str(row["article_no"]) for row in fetch_rows)
                fetch_command = [
                    sys.executable,
                    "-B",
                    str(FETCHER),
                    "--realtor-id",
                    str(realtor_id),
                    "--limit",
                    str(len(fetch_rows)),
                    "--article-nos",
                    article_nos,
                    "--headless",
                    "1",
                ]
                print(
                    f"[MULTI WEBZINE DETAIL BATCH] realtor_id={realtor_id} "
                    f"targets={len(fetch_rows)} browser_runs=1"
                )
                fetch_exit = run_command(fetch_command, "MULTI WEBZINE DETAIL BATCH FETCH")
                conn.rollback()

            for row in batch_rows:
                article_no = str(row["article_no"])
                if fetch_exit != 0 or not detail_completed(conn, realtor_id, article_no):
                    detail_failed += 1
                    reason = detail_error(conn, realtor_id, article_no)
                    failed_items.append((realtor_id, article_no, f"detail:{reason}"))
                    print(
                        f"[MULTI WEBZINE DETAIL FAILED] realtor_id={realtor_id} "
                        f"article_no={article_no} exit={fetch_exit} reason={reason}"
                    )
                else:
                    detail_success += 1
                    date_ok, date_source, registered_at = sync_one_naver_date(
                        conn, realtor_id, article_no
                    )
                    if not date_ok:
                        detail_failed += 1
                        failed_items.append((realtor_id, article_no, f"naver_date:{date_source}"))
                        print(
                            f"[MULTI WEBZINE NAVER DATE FAILED] realtor_id={realtor_id} "
                            f"article_no={article_no} reason={date_source}"
                        )
                    else:
                        print(
                            f"[MULTI WEBZINE NAVER DATE] realtor_id={realtor_id} "
                            f"article_no={article_no} source={date_source} "
                            f"registered_at={registered_at:%Y-%m-%d}"
                        )
            if batch_index < len(target_batches) and args.sleep > 0:
                time.sleep(max(0.0, float(args.sleep)))

        # 상세수집에 성공한 현재 매물만 기사화한다. 기존 상세수집 완료 매물은
        # 중복수집하지 않고 갱신하며, 실패 매물은 다음 회차 대상에 다시 포함된다.
        conn.commit()
        if not args.skip_webzine_generate:
            for realtor in realtors:
                current_realtor_id = int(realtor["realtor_id"])
                generate_command = [
                    sys.executable,
                    "-B",
                    str(GENERATOR),
                    "--realtor-id",
                    str(current_realtor_id),
                    "--sync-all-current",
                    "--skip-policy-feeds",
                ]
                if run_command(generate_command, "MULTI WEBZINE ARTICLE ALL CURRENT") == 0:
                    generated += 1
                    with conn.cursor() as cur:
                        cur.execute(
                            "UPDATE multi_sites SET updated_at=NOW() WHERE realtor_id=%s AND status='active'",
                            (current_realtor_id,),
                        )
                    conn.commit()
                else:
                    generation_failed += 1
                    failed_items.append(
                        (current_realtor_id, "*", "generate_all_current:nonzero_exit")
                    )

        print(
            f"[MULTI WEBZINE DETAIL DONE] targets={len(targets)} "
            f"detail_success={detail_success} detail_failed={detail_failed} "
            f"generated_realtors={generated} generation_failed={generation_failed}"
        )
        for realtor_id, article_no, reason in failed_items:
            print(
                f"[MULTI WEBZINE DETAIL FAILURE SUMMARY] realtor_id={realtor_id} "
                f"article_no={article_no} reason={reason}"
            )
        conn.rollback()
        for realtor in realtors:
            current_realtor_id = int(realtor["realtor_id"])
            print_reconciliation(
                conn, current_realtor_id, snapshots[current_realtor_id], "AFTER"
            )
        if generation_failed:
            raise SystemExit(1)
    finally:
        if locked:
            release_lock(conn)
        conn.close()


if __name__ == "__main__":
    main()
