# realestate-lab/app/db.py
import json
import os
import sqlite3
import logging
from typing import Dict, Any, List, Optional
from datetime import date

logger = logging.getLogger("realestate-lab")

DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")


def _conn():
    c = sqlite3.connect(DB_PATH, timeout=120.0)
    c.row_factory = sqlite3.Row
    c.execute("PRAGMA journal_mode=WAL;")
    c.execute("PRAGMA busy_timeout=120000;")
    c.execute("PRAGMA foreign_keys=ON;")
    return c


def init_db():
    with _conn() as conn:
        # ── announcements ────────────────────────────────────────────────
        conn.execute("""
            CREATE TABLE IF NOT EXISTS announcements (
                id                  INTEGER PRIMARY KEY AUTOINCREMENT,
                house_manage_no     TEXT    NOT NULL,
                pblanc_no           TEXT    NOT NULL,
                house_nm            TEXT,
                house_secd          TEXT,
                house_dtl_secd      TEXT,
                rent_secd           TEXT,
                region_code         TEXT,
                region_name         TEXT,
                address             TEXT,
                total_units         INTEGER,
                rcrit_date          TEXT,
                receipt_start       TEXT,
                receipt_end         TEXT,
                spsply_start        TEXT,
                spsply_end          TEXT,
                gnrl_rank1_start    TEXT,
                gnrl_rank1_end      TEXT,
                winner_date         TEXT,
                contract_start      TEXT,
                contract_end        TEXT,
                homepage_url        TEXT,
                pblanc_url          TEXT,
                constructor         TEXT,
                developer           TEXT,
                move_in_month       TEXT,
                is_speculative_area TEXT,
                is_price_cap        TEXT,
                contact             TEXT,
                status              TEXT NOT NULL DEFAULT '청약예정',
                is_bookmarked       INTEGER NOT NULL DEFAULT 0,
                source              TEXT NOT NULL DEFAULT 'manual',
                created_at          TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
                updated_at          TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
                UNIQUE(house_manage_no, pblanc_no)
            );
        """)
        conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_status ON announcements(status);")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_region ON announcements(region_name);")

        # ── 마이그레이션: district 컬럼 + 인덱스 추가 ──
        try:
            conn.execute("SELECT district FROM announcements LIMIT 1")
        except Exception:
            conn.execute("ALTER TABLE announcements ADD COLUMN district TEXT")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);")

        # ── 마이그레이션: is_bookmarked 컬럼 추가 ──
        try:
            conn.execute("SELECT is_bookmarked FROM announcements LIMIT 1")
        except Exception:
            conn.execute("ALTER TABLE announcements ADD COLUMN is_bookmarked INTEGER NOT NULL DEFAULT 0")

        # ── announcement_models ──────────────────────────────────────────
        conn.execute("""
            CREATE TABLE IF NOT EXISTS announcement_models (
                id                  INTEGER PRIMARY KEY AUTOINCREMENT,
                house_manage_no     TEXT NOT NULL,
                pblanc_no           TEXT NOT NULL,
                model_no            TEXT,
                house_ty            TEXT,
                supply_area         REAL,
                general_units       INTEGER DEFAULT 0,
                special_units       INTEGER DEFAULT 0,
                multi_child_units   INTEGER DEFAULT 0,
                newlywed_units      INTEGER DEFAULT 0,
                first_life_units    INTEGER DEFAULT 0,
                old_parent_units    INTEGER DEFAULT 0,
                institution_units   INTEGER DEFAULT 0,
                youth_units         INTEGER DEFAULT 0,
                newborn_units       INTEGER DEFAULT 0,
                top_amount          INTEGER,
                UNIQUE(house_manage_no, pblanc_no, model_no)
            );
        """)

        # ── user_profile ─────────────────────────────────────────────────
        conn.execute("""
            CREATE TABLE IF NOT EXISTS user_profile (
                id                  INTEGER PRIMARY KEY DEFAULT 1,
                name                TEXT,
                age                 INTEGER,
                is_homeless         INTEGER,
                is_householder      INTEGER,
                subscription_months INTEGER,
                subscription_amount INTEGER,
                family_members      INTEGER,
                has_dependents      INTEGER,
                children_count      INTEGER DEFAULT 0,
                is_newlywed         INTEGER,
                marriage_months     INTEGER,
                has_newborn         INTEGER,
                is_first_home       INTEGER,
                income_level        TEXT,
                preferred_regions   TEXT NOT NULL DEFAULT '[]',
                preferred_types     TEXT NOT NULL DEFAULT '[]',
                min_area            REAL,
                max_area            REAL,
                max_price           INTEGER,
                updated_at          TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            );
        """)

        # ── 마이그레이션: user_profile 신규 3컬럼 ──
        for col, ddl in (
            ("preferred_districts", "ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}'"),
            ("min_match_score",    "ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70"),
            ("notify_enabled",     "ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1"),
        ):
            try:
                conn.execute(f"SELECT {col} FROM user_profile LIMIT 1")
            except Exception:
                conn.execute(ddl)

        # ── match_results ────────────────────────────────────────────────
        conn.execute("""
            CREATE TABLE IF NOT EXISTS match_results (
                id                  INTEGER PRIMARY KEY AUTOINCREMENT,
                announcement_id     INTEGER NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
                model_id            INTEGER,
                match_score         INTEGER NOT NULL DEFAULT 0,
                match_reasons       TEXT NOT NULL DEFAULT '[]',
                eligible_types      TEXT NOT NULL DEFAULT '[]',
                is_new              INTEGER NOT NULL DEFAULT 1,
                created_at          TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
                UNIQUE(announcement_id, model_id)
            );
        """)

        # ── 마이그레이션: notified_at 컬럼 추가 ──
        try:
            conn.execute("SELECT notified_at FROM match_results LIMIT 1")
        except Exception:
            conn.execute("ALTER TABLE match_results ADD COLUMN notified_at TEXT")

        # ── 마이그레이션: score_breakdown 컬럼 추가 ──
        try:
            conn.execute("SELECT score_breakdown FROM match_results LIMIT 1")
        except Exception:
            conn.execute("ALTER TABLE match_results ADD COLUMN score_breakdown TEXT")

        # ── collect_log ──────────────────────────────────────────────────
        conn.execute("""
            CREATE TABLE IF NOT EXISTS collect_log (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
                new_count   INTEGER NOT NULL DEFAULT 0,
                total_count INTEGER NOT NULL DEFAULT 0,
                error       TEXT
            );
        """)

        # ── listings (매물 파이프라인) ──────────────────────────────────────
        conn.execute("""
            CREATE TABLE IF NOT EXISTS listings (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                article_no TEXT UNIQUE, source TEXT NOT NULL, deal_type TEXT NOT NULL,
                deposit INTEGER, monthly_rent INTEGER, sale_price INTEGER,
                area_exclusive REAL, floor TEXT, complex_name TEXT, address TEXT,
                dong TEXT, dong_code TEXT, url TEXT, posted_at TEXT, raw_json TEXT,
                created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
                notified_at TEXT
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS market_deals (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                house_type TEXT, dong_code TEXT, dong TEXT, complex_name TEXT, area REAL,
                deal_type TEXT, deposit INTEGER, monthly_rent INTEGER, sale_amount INTEGER,
                deal_ym TEXT, floor TEXT, source TEXT DEFAULT 'molit',
                created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)
        # 플랜 원안은 table-level UNIQUE(...)였으나 SQLite는 UNIQUE 제약에서 NULL을 서로 다른 값으로
        # 취급해 deposit/sale_amount(둘 중 하나는 항상 NULL)가 있는 행에서 dedup이 깨진다.
        # (표현식은 table-level UNIQUE에 사용 불가 → CREATE UNIQUE INDEX + COALESCE로 대체.
        #  실제 컬럼값은 그대로 NULL 유지 — downstream get_market_deals_for/listing_matcher 영향 없음.)
        conn.execute("""
            CREATE UNIQUE INDEX IF NOT EXISTS ux_market_deals_dedup ON market_deals(
                dong_code, COALESCE(complex_name,''), area, deal_ym, deal_type,
                COALESCE(deposit,-1), COALESCE(sale_amount,-1), COALESCE(floor,'')
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS listing_matches (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                listing_id INTEGER UNIQUE REFERENCES listings(id) ON DELETE CASCADE,
                category TEXT, passed INTEGER, match_score INTEGER,
                jeonse_ratio REAL, safety_tier TEXT, price_ratio REAL, valuation_tier TEXT,
                market_median INTEGER, sample_size INTEGER, budget_ok INTEGER,
                regulation_flags TEXT, reasons TEXT, is_new INTEGER,
                notified_at TEXT,
                created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS listing_criteria (
                id INTEGER PRIMARY KEY DEFAULT 1,
                dongs TEXT NOT NULL DEFAULT '[]', deal_types TEXT NOT NULL DEFAULT '[]',
                max_deposit INTEGER, max_sale_price INTEGER, min_area REAL,
                house_types TEXT NOT NULL DEFAULT '[]', min_safety_tier TEXT,
                notify_enabled INTEGER NOT NULL DEFAULT 1,
                equity INTEGER, annual_income INTEGER,
                is_homeless INTEGER NOT NULL DEFAULT 1, is_householder INTEGER NOT NULL DEFAULT 0,
                is_first_home INTEGER NOT NULL DEFAULT 0,
                updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS listing_collect_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                new_count INTEGER, total_count INTEGER, naver_ok INTEGER,
                error TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)
        conn.execute("CREATE INDEX IF NOT EXISTS idx_md_lookup ON market_deals(dong_code, deal_type, area, deal_ym);")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_listing_dong ON listings(dong, deal_type);")


# ── 상태 자동 계산 ───────────────────────────────────────────────────────────

def compute_status(receipt_start: str, receipt_end: str, winner_date: str) -> str:
    today = date.today().isoformat()
    if receipt_start and today < receipt_start:
        return "청약예정"
    if receipt_start and receipt_end and receipt_start <= today <= receipt_end:
        return "청약중"
    if receipt_end and winner_date and receipt_end < today <= winner_date:
        return "결과발표"
    if winner_date and today > winner_date:
        return "완료"
    return "청약예정"


# ── announcements CRUD ───────────────────────────────────────────────────────

def _ann_row_to_dict(r) -> Dict[str, Any]:
    return {c: r[c] for c in r.keys()}


def upsert_announcement(data: Dict[str, Any]) -> tuple:
    """공고 upsert — house_manage_no + pblanc_no 기준. Returns (dict, is_new: bool)."""
    data.setdefault("district", None)  # 수동 등록 등에서 누락 시 안전 처리
    status = compute_status(
        data.get("receipt_start", ""),
        data.get("receipt_end", ""),
        data.get("winner_date", ""),
    )
    with _conn() as conn:
        exists = conn.execute(
            "SELECT 1 FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
            (data["house_manage_no"], data["pblanc_no"]),
        ).fetchone()
        is_new = exists is None
        conn.execute("""
            INSERT INTO announcements (
                house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd,
                rent_secd, region_code, region_name, district, address, total_units,
                rcrit_date, receipt_start, receipt_end, spsply_start, spsply_end,
                gnrl_rank1_start, gnrl_rank1_end, winner_date, contract_start,
                contract_end, homepage_url, pblanc_url, constructor, developer,
                move_in_month, is_speculative_area, is_price_cap, contact,
                status, source
            ) VALUES (
                :house_manage_no, :pblanc_no, :house_nm, :house_secd, :house_dtl_secd,
                :rent_secd, :region_code, :region_name, :district, :address, :total_units,
                :rcrit_date, :receipt_start, :receipt_end, :spsply_start, :spsply_end,
                :gnrl_rank1_start, :gnrl_rank1_end, :winner_date, :contract_start,
                :contract_end, :homepage_url, :pblanc_url, :constructor, :developer,
                :move_in_month, :is_speculative_area, :is_price_cap, :contact,
                :status, :source
            )
            ON CONFLICT(house_manage_no, pblanc_no) DO UPDATE SET
                house_nm=excluded.house_nm,
                house_secd=excluded.house_secd,
                house_dtl_secd=excluded.house_dtl_secd,
                rent_secd=excluded.rent_secd,
                region_code=excluded.region_code,
                region_name=excluded.region_name,
                district=excluded.district,
                address=excluded.address,
                total_units=excluded.total_units,
                rcrit_date=excluded.rcrit_date,
                receipt_start=excluded.receipt_start,
                receipt_end=excluded.receipt_end,
                spsply_start=excluded.spsply_start,
                spsply_end=excluded.spsply_end,
                gnrl_rank1_start=excluded.gnrl_rank1_start,
                gnrl_rank1_end=excluded.gnrl_rank1_end,
                winner_date=excluded.winner_date,
                contract_start=excluded.contract_start,
                contract_end=excluded.contract_end,
                homepage_url=excluded.homepage_url,
                pblanc_url=excluded.pblanc_url,
                constructor=excluded.constructor,
                developer=excluded.developer,
                move_in_month=excluded.move_in_month,
                is_speculative_area=excluded.is_speculative_area,
                is_price_cap=excluded.is_price_cap,
                contact=excluded.contact,
                status=excluded.status,
                updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
        """, {**data, "status": status})
        row = conn.execute(
            "SELECT * FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
            (data["house_manage_no"], data["pblanc_no"]),
        ).fetchone()
    return _ann_row_to_dict(row), is_new


def _enrich_items(conn, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """공고 목록에 모델 기반 가격 범위 + 매칭 점수를 추가한다."""
    for item in items:
        ann_id = item.get("id")
        hmno = item.get("house_manage_no")
        pno = item.get("pblanc_no")
        # 가격 정보
        if hmno and pno:
            price_row = conn.execute(
                "SELECT MIN(top_amount) as min_price, MAX(top_amount) as max_price "
                "FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ? AND top_amount IS NOT NULL",
                (hmno, pno),
            ).fetchone()
            if price_row and price_row["min_price"] is not None:
                item["min_price"] = price_row["min_price"]
                item["max_price_display"] = price_row["max_price"]
        # 매칭 점수
        if ann_id:
            match_row = conn.execute(
                "SELECT match_score, match_reasons, eligible_types, score_breakdown FROM match_results WHERE announcement_id = ?",
                (ann_id,),
            ).fetchone()
            if match_row:
                item["match_score"] = match_row["match_score"]
                item["match_reasons"] = json.loads(match_row["match_reasons"]) if match_row["match_reasons"] else []
                item["eligible_types"] = json.loads(match_row["eligible_types"]) if match_row["eligible_types"] else []
                item["score_breakdown"] = json.loads(match_row["score_breakdown"]) if match_row["score_breakdown"] else None
    return items


def get_announcements(
    region: str = None,
    status: str = None,
    house_type: str = None,
    matched_only: bool = False,
    bookmarked: bool = False,
    sort: str = "date",
    page: int = 1,
    size: int = 20,
) -> Dict[str, Any]:
    conditions, params = [], []
    if region:
        conditions.append("a.region_name LIKE ?")
        params.append(f"%{region}%")
    if status:
        conditions.append("a.status = ?")
        params.append(status)
    if house_type:
        conditions.append("a.house_secd = ?")
        params.append(house_type)
    if bookmarked:
        conditions.append("a.is_bookmarked = 1")

    if matched_only:
        conditions.append("a.id IN (SELECT announcement_id FROM match_results)")

    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""

    order_map = {"date": "a.rcrit_date DESC", "score": "a.id DESC", "price": "a.id ASC"}
    order = order_map.get(sort, "a.rcrit_date DESC")
    if matched_only and sort == "score":
        order = "(SELECT MAX(match_score) FROM match_results WHERE announcement_id = a.id) DESC"

    offset = (page - 1) * size

    with _conn() as conn:
        total = conn.execute(
            f"SELECT COUNT(*) FROM announcements a {where}", params
        ).fetchone()[0]
        rows = conn.execute(
            f"SELECT a.* FROM announcements a {where} ORDER BY {order} LIMIT ? OFFSET ?",
            params + [size, offset],
        ).fetchall()
        items = [_ann_row_to_dict(r) for r in rows]
        items = _enrich_items(conn, items)
    return {
        "items": items,
        "total": total,
        "page": page,
        "size": size,
    }


def get_announcement(ann_id: int) -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
        if not row:
            return None
        ann = _ann_row_to_dict(row)
        models = conn.execute(
            "SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?",
            (ann["house_manage_no"], ann["pblanc_no"]),
        ).fetchall()
        ann["models"] = [dict(m) for m in models]
    return ann


def create_announcement(data: Dict[str, Any]) -> Dict[str, Any]:
    """수동 공고 등록 (house_manage_no 자동 생성)."""
    import uuid
    data["house_manage_no"] = data.get("house_manage_no", f"MANUAL-{uuid.uuid4().hex[:8]}")
    data["pblanc_no"] = data.get("pblanc_no", "00")
    data["source"] = "manual"
    result, _ = upsert_announcement(data)
    return result


ANNOUNCEMENT_COLUMNS = {
    "house_nm", "house_secd", "house_dtl_secd", "rent_secd",
    "region_code", "region_name", "district", "address", "total_units",
    "rcrit_date", "receipt_start", "receipt_end", "spsply_start", "spsply_end",
    "gnrl_rank1_start", "gnrl_rank1_end", "winner_date",
    "contract_start", "contract_end", "homepage_url", "pblanc_url",
    "constructor", "developer", "move_in_month",
    "is_speculative_area", "is_price_cap", "contact",
}


def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    fields = {k: v for k, v in data.items() if v is not None and k in ANNOUNCEMENT_COLUMNS}
    if not fields:
        return get_announcement(ann_id)

    # 날짜 변경 시 status 재계산
    with _conn() as conn:
        row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
        if not row:
            return None
        current = _ann_row_to_dict(row)
        merged = {**current, **fields}
        status = compute_status(
            merged.get("receipt_start", ""),
            merged.get("receipt_end", ""),
            merged.get("winner_date", ""),
        )
        fields["status"] = status

        set_clauses = ", ".join(f"{k} = ?" for k in fields)
        set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
        conn.execute(
            f"UPDATE announcements SET {set_clauses} WHERE id = ?",
            list(fields.values()) + [ann_id],
        )
    return get_announcement(ann_id)


def toggle_bookmark(ann_id: int) -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        row = conn.execute("SELECT id, is_bookmarked FROM announcements WHERE id = ?", (ann_id,)).fetchone()
        if not row:
            return None
        new_val = 0 if row["is_bookmarked"] else 1
        conn.execute(
            "UPDATE announcements SET is_bookmarked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
            (new_val, ann_id),
        )
        updated = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
    return _ann_row_to_dict(updated)


def delete_announcement(ann_id: int) -> bool:
    with _conn() as conn:
        # match_results는 FK CASCADE로 자동 삭제
        cur = conn.execute("DELETE FROM announcements WHERE id = ?", (ann_id,))
    return cur.rowcount > 0


def delete_closed_announcements() -> int:
    """status='완료' 공고 일괄 삭제. 삭제된 건수 반환."""
    with _conn() as conn:
        cur = conn.execute("DELETE FROM announcements WHERE status = '완료'")
    return cur.rowcount


def delete_old_completed_announcements(grace_days: int = 90) -> int:
    """winner_date + grace_days 경과한 status='완료' 공고를 삭제.
    winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
    match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
    """
    with _conn() as conn:
        cur = conn.execute(
            """
            DELETE FROM announcements
            WHERE status = '완료'
              AND winner_date IS NOT NULL
              AND date(winner_date) < date('now', ?)
            """,
            (f"-{grace_days} days",),
        )
    return cur.rowcount


def update_all_statuses():
    """모든 진행중 공고의 status를 날짜 기반으로 재계산."""
    with _conn() as conn:
        rows = conn.execute(
            "SELECT id, status, receipt_start, receipt_end, winner_date FROM announcements "
            "WHERE status != '완료' AND (receipt_start IS NOT NULL OR receipt_end IS NOT NULL OR winner_date IS NOT NULL)"
        ).fetchall()
        for r in rows:
            new_status = compute_status(r["receipt_start"], r["receipt_end"], r["winner_date"])
            if new_status != r["status"]:  # only update if status actually changed
                conn.execute(
                    "UPDATE announcements SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
                    (new_status, r["id"]),
                )


# ── announcement_models CRUD ─────────────────────────────────────────────────

def upsert_model(data: Dict[str, Any]):
    with _conn() as conn:
        conn.execute("""
            INSERT INTO announcement_models (
                house_manage_no, pblanc_no, model_no, house_ty, supply_area,
                general_units, special_units, multi_child_units, newlywed_units,
                first_life_units, old_parent_units, institution_units,
                youth_units, newborn_units, top_amount
            ) VALUES (
                :house_manage_no, :pblanc_no, :model_no, :house_ty, :supply_area,
                :general_units, :special_units, :multi_child_units, :newlywed_units,
                :first_life_units, :old_parent_units, :institution_units,
                :youth_units, :newborn_units, :top_amount
            )
            ON CONFLICT(house_manage_no, pblanc_no, model_no) DO UPDATE SET
                house_ty=excluded.house_ty,
                supply_area=excluded.supply_area,
                general_units=excluded.general_units,
                special_units=excluded.special_units,
                multi_child_units=excluded.multi_child_units,
                newlywed_units=excluded.newlywed_units,
                first_life_units=excluded.first_life_units,
                old_parent_units=excluded.old_parent_units,
                institution_units=excluded.institution_units,
                youth_units=excluded.youth_units,
                newborn_units=excluded.newborn_units,
                top_amount=excluded.top_amount
        """, data)


# ── 청약 가점 계산 ───────────────────────────────────────────────────────────

def calculate_subscription_points(profile: Dict[str, Any]) -> Dict[str, Any]:
    """청약 가점제 점수 계산 (총 84점 만점).

    1. 무주택기간 (0~32점): 만 30세부터 기산, 연 2점
    2. 부양가족 수 (0~35점): 인당 5점, 6명+ 만점
    3. 청약통장 가입기간 (0~17점): 6개월 미만 1점 ~ 15년+ 17점
    """
    result = {
        "homeless_duration": {"score": 0, "max": 32, "detail": ""},
        "dependents": {"score": 0, "max": 35, "detail": ""},
        "subscription_period": {"score": 0, "max": 17, "detail": ""},
        "total": 0,
        "max_total": 84,
    }

    if not profile:
        return result

    # 1. 무주택기간 (만 30세부터 기산, 연 2점, 최대 32점)
    age = profile.get("age") or 0
    is_homeless = profile.get("is_homeless", False)
    if is_homeless and age >= 30:
        homeless_years = age - 30
        score = min(homeless_years * 2, 32)
        # 1년 미만도 2점
        if homeless_years == 0:
            score = 2
        result["homeless_duration"]["score"] = score
        result["homeless_duration"]["detail"] = f"만 {age}세, 무주택 약 {homeless_years}년"
    elif is_homeless and age < 30:
        result["homeless_duration"]["score"] = 0
        result["homeless_duration"]["detail"] = f"만 {age}세 (30세 미만, 기간 미산정)"
    else:
        result["homeless_duration"]["detail"] = "유주택자"

    # 2. 부양가족 수 (인당 5점, 최대 35점)
    family_members = profile.get("family_members") or 0
    dependents = max(family_members - 1, 0)  # 본인 제외
    dep_score = min(dependents * 5, 35)
    result["dependents"]["score"] = dep_score
    result["dependents"]["detail"] = f"{dependents}명" if dependents > 0 else "0명 (본인만)"

    # 3. 청약통장 가입기간 (6개월 미만 1점, 이후 1년마다 +1점, 최대 17점)
    months = profile.get("subscription_months") or 0
    if months <= 0:
        sub_score = 0
        sub_detail = "미가입"
    elif months < 6:
        sub_score = 1
        sub_detail = f"{months}개월 (6개월 미만)"
    else:
        years = months / 12
        # 6개월~1년 = 2점, 1~2년 = 3점, ..., 14~15년 = 16점, 15년+ = 17점
        sub_score = min(int(years) + 2, 17)
        if years < 1:
            sub_score = 2
        if years >= 1:
            y = int(years)
            sub_detail = f"{y}년 {months - y*12}개월"
        else:
            sub_detail = f"{months}개월"
    result["subscription_period"]["score"] = sub_score
    result["subscription_period"]["detail"] = sub_detail

    result["total"] = (
        result["homeless_duration"]["score"]
        + result["dependents"]["score"]
        + result["subscription_period"]["score"]
    )

    return result


# ── user_profile CRUD ────────────────────────────────────────────────────────

def _profile_row_to_dict(r) -> Dict[str, Any]:
    d = {}
    for c in r.keys():
        val = r[c]
        if c in ("is_homeless", "is_householder", "has_dependents", "is_newlywed",
                  "has_newborn", "is_first_home", "notify_enabled"):
            d[c] = bool(val) if val is not None else None
        elif c in ("preferred_regions", "preferred_types"):
            d[c] = json.loads(val) if val else []
        elif c == "preferred_districts":
            d[c] = json.loads(val) if val else {}
        else:
            d[c] = val
    return d


def get_profile() -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        r = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
    if not r:
        return None
    profile = _profile_row_to_dict(r)
    profile["subscription_points"] = calculate_subscription_points(profile)
    return profile


PROFILE_COLUMNS = {
    "name", "age", "is_homeless", "is_householder",
    "subscription_months", "subscription_amount", "family_members",
    "has_dependents", "children_count", "is_newlywed", "marriage_months",
    "has_newborn", "is_first_home", "income_level",
    "preferred_regions", "preferred_types", "preferred_districts",
    "min_area", "max_area", "max_price",
    "min_match_score", "notify_enabled",
}


def upsert_profile(data: Dict[str, Any]) -> Dict[str, Any]:
    updates = {}
    for k, v in data.items():
        if v is None or k not in PROFILE_COLUMNS:
            continue
        if isinstance(v, bool):
            updates[k] = 1 if v else 0
        elif isinstance(v, (list, dict)):
            updates[k] = json.dumps(v)
        else:
            updates[k] = v

    with _conn() as conn:
        existing = conn.execute("SELECT id FROM user_profile WHERE id = 1").fetchone()
        if existing:
            if updates:
                set_clauses = ", ".join(f"{k} = ?" for k in updates)
                set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
                conn.execute(
                    f"UPDATE user_profile SET {set_clauses} WHERE id = 1",
                    list(updates.values()),
                )
        else:
            cols = ["id"] + list(updates.keys())
            vals = [1] + list(updates.values())
            placeholders = ", ".join("?" for _ in vals)
            conn.execute(
                f"INSERT INTO user_profile ({', '.join(cols)}) VALUES ({placeholders})",
                vals,
            )
        row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
    profile = _profile_row_to_dict(row)
    profile["subscription_points"] = calculate_subscription_points(profile)
    return profile


# ── match_results CRUD ───────────────────────────────────────────────────────

def save_match_result(data: Dict[str, Any]):
    with _conn() as conn:
        conn.execute("""
            INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
            VALUES (:announcement_id, :model_id, :match_score, :match_reasons, :eligible_types, 1)
            ON CONFLICT(announcement_id, model_id) DO UPDATE SET
                match_score=excluded.match_score,
                match_reasons=excluded.match_reasons,
                eligible_types=excluded.eligible_types
        """, {
            **data,
            "match_reasons": json.dumps(data.get("match_reasons", [])),
            "eligible_types": json.dumps(data.get("eligible_types", [])),
        })


def get_matches(page: int = 1, size: int = 20) -> Dict[str, Any]:
    offset = (page - 1) * size
    with _conn() as conn:
        # 프로필 가점 계산
        profile_row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
        points = None
        if profile_row:
            profile = _profile_row_to_dict(profile_row)
            points = calculate_subscription_points(profile)

        total = conn.execute("SELECT COUNT(*) FROM match_results").fetchone()[0]
        rows = conn.execute("""
            SELECT m.*, a.house_nm, a.region_name, a.address, a.district, a.status as ann_status,
                   a.receipt_start, a.receipt_end, a.winner_date, a.pblanc_url,
                   a.house_secd, a.is_speculative_area
            FROM match_results m
            JOIN announcements a ON a.id = m.announcement_id
            ORDER BY m.is_new DESC, m.match_score DESC
            LIMIT ? OFFSET ?
        """, (size, offset)).fetchall()

    items = []
    for r in rows:
        d = {c: r[c] for c in r.keys()}
        d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
        d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
        d["score_breakdown"] = json.loads(d["score_breakdown"]) if d.get("score_breakdown") else None
        items.append(d)
    return {
        "items": items,
        "total": total,
        "page": page,
        "size": size,
        "my_points": points,
    }


def mark_match_read(match_id: int) -> bool:
    with _conn() as conn:
        cur = conn.execute("UPDATE match_results SET is_new = 0 WHERE id = ?", (match_id,))
    return cur.rowcount > 0


def get_unnotified_matches(min_score: int) -> List[Dict[str, Any]]:
    """notified_at IS NULL AND match_score >= min_score 인 매칭과 공고 정보 조인 반환."""
    with _conn() as conn:
        rows = conn.execute("""
            SELECT m.id, m.announcement_id, m.match_score, m.match_reasons, m.eligible_types,
                   a.house_nm, a.region_name, a.district, a.address, a.status,
                   a.receipt_start, a.receipt_end, a.winner_date,
                   a.house_secd, a.is_speculative_area, a.is_price_cap, a.pblanc_url
            FROM match_results m
            JOIN announcements a ON a.id = m.announcement_id
            WHERE m.notified_at IS NULL
              AND m.match_score >= ?
            ORDER BY m.match_score DESC
        """, (min_score,)).fetchall()
    items = []
    for r in rows:
        d = {c: r[c] for c in r.keys()}
        d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
        d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
        items.append(d)
    return items


def mark_matches_notified(match_ids: List[int]) -> None:
    """주어진 match_results IDs의 notified_at을 현재 시각으로 일괄 업데이트."""
    if not match_ids:
        return
    placeholders = ",".join("?" for _ in match_ids)
    with _conn() as conn:
        conn.execute(
            f"UPDATE match_results SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') "
            f"WHERE id IN ({placeholders})",
            tuple(match_ids),
        )


# ── collect_log CRUD ─────────────────────────────────────────────────────────

def save_collect_log(new_count: int, total_count: int, error: str = None):
    with _conn() as conn:
        conn.execute(
            "INSERT INTO collect_log (new_count, total_count, error) VALUES (?, ?, ?)",
            (new_count, total_count, error),
        )


def get_last_collect_log() -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        r = conn.execute("SELECT * FROM collect_log ORDER BY id DESC LIMIT 1").fetchone()
    return dict(r) if r else None


# ── 대시보드 ─────────────────────────────────────────────────────────────────

def get_dashboard() -> Dict[str, Any]:
    with _conn() as conn:
        active = conn.execute(
            "SELECT COUNT(*) FROM announcements WHERE status IN ('청약예정', '청약중')"
        ).fetchone()[0]
        new_matches = conn.execute(
            "SELECT COUNT(*) FROM match_results WHERE is_new = 1"
        ).fetchone()[0]
        bookmarked_count = conn.execute(
            "SELECT COUNT(*) FROM announcements WHERE is_bookmarked = 1"
        ).fetchone()[0]
        profile_row = conn.execute(
            "SELECT min_match_score FROM user_profile WHERE id = 1"
        ).fetchone()
        min_score = profile_row["min_match_score"] if profile_row else 70
        pass_count = conn.execute(
            "SELECT COUNT(*) FROM match_results WHERE match_score >= ?", (min_score,)
        ).fetchone()[0]

        # 다가오는 일정을 개별 이벤트로 분해
        upcoming_rows = conn.execute("""
            SELECT id, house_nm, receipt_start, receipt_end,
                   spsply_start, gnrl_rank1_start, winner_date,
                   contract_start, status
            FROM announcements
            WHERE status IN ('청약예정', '청약중')
            ORDER BY receipt_start ASC
            LIMIT 20
        """).fetchall()

        today = date.today().isoformat()
        schedules = []
        for r in upcoming_rows:
            events = [
                ("특별공급 접수", r["spsply_start"]),
                ("1순위 접수", r["gnrl_rank1_start"]),
                ("청약 접수", r["receipt_start"]),
                ("당첨자 발표", r["winner_date"]),
                ("계약 시작", r["contract_start"]),
            ]
            for event, d in events:
                if d and d >= today:
                    schedules.append({
                        "announcement_id": r["id"],
                        "house_nm": r["house_nm"],
                        "event": event,
                        "date": d,
                    })
        schedules.sort(key=lambda s: s["date"])
        schedules = schedules[:10]

        # 즐겨찾기 공고
        bookmarked_rows = conn.execute("""
            SELECT * FROM announcements WHERE is_bookmarked = 1
            ORDER BY receipt_start ASC
        """).fetchall()
        bookmarked_items = [_ann_row_to_dict(r) for r in bookmarked_rows]
        bookmarked_items = _enrich_items(conn, bookmarked_items)

    return {
        "active_count": active,
        "new_match_count": new_matches,
        "pass_count": pass_count,
        "bookmarked_count": bookmarked_count,
        "upcoming_schedules": schedules,
        "bookmarked": bookmarked_items,
    }


# ── 매물 파이프라인 (listings/market_deals/listing_matches/listing_criteria) ──

_LISTING_CRITERIA_SEED = {
    "dongs": ["신대방동", "대방동", "상도동", "봉천동", "대림동", "신길동"],
    "deal_types": ["전세", "반전세", "매매"],
    "max_deposit": 32000, "max_sale_price": None, "min_area": 40.0,
    "house_types": ["아파트", "오피스텔", "빌라"], "min_safety_tier": None,
    "notify_enabled": 1, "equity": None, "annual_income": None,
    "is_homeless": 1, "is_householder": 0, "is_first_home": 0,
}
_LC_JSON_COLS = ("dongs", "deal_types", "house_types")


def get_listing_criteria() -> Dict[str, Any]:
    with _conn() as conn:
        row = conn.execute("SELECT * FROM listing_criteria WHERE id=1").fetchone()
        if row is None:
            conn.execute(
                """INSERT INTO listing_criteria
                   (id,dongs,deal_types,max_deposit,max_sale_price,min_area,house_types,
                    min_safety_tier,notify_enabled,equity,annual_income,is_homeless,is_householder,is_first_home)
                   VALUES (1,:dongs,:deal_types,:max_deposit,:max_sale_price,:min_area,:house_types,
                    :min_safety_tier,:notify_enabled,:equity,:annual_income,:is_homeless,:is_householder,:is_first_home)""",
                {**_LISTING_CRITERIA_SEED,
                 "dongs": json.dumps(_LISTING_CRITERIA_SEED["dongs"], ensure_ascii=False),
                 "deal_types": json.dumps(_LISTING_CRITERIA_SEED["deal_types"], ensure_ascii=False),
                 "house_types": json.dumps(_LISTING_CRITERIA_SEED["house_types"], ensure_ascii=False)},
            )
            row = conn.execute("SELECT * FROM listing_criteria WHERE id=1").fetchone()
    d = dict(row)
    for c in _LC_JSON_COLS:
        d[c] = json.loads(d[c] or "[]")
    return d


def update_listing_criteria(fields: Dict[str, Any]) -> None:
    if not fields:
        return
    sets, vals = [], []
    for k, v in fields.items():
        if v is None and k not in ("max_deposit", "max_sale_price", "equity", "annual_income", "min_safety_tier"):
            continue
        sets.append(f"{k}=?")
        vals.append(json.dumps(v, ensure_ascii=False) if k in _LC_JSON_COLS else v)
    if not sets:
        return
    sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
    with _conn() as conn:
        conn.execute("INSERT OR IGNORE INTO listing_criteria(id) VALUES(1)")
        conn.execute(f"UPDATE listing_criteria SET {','.join(sets)} WHERE id=1", vals)


def upsert_listing(data: Dict[str, Any]) -> tuple:
    cols = ("article_no", "source", "deal_type", "deposit", "monthly_rent", "sale_price",
            "area_exclusive", "floor", "complex_name", "address", "dong", "dong_code",
            "url", "posted_at", "raw_json")
    d = {c: data.get(c) for c in cols}
    with _conn() as conn:
        exists = conn.execute("SELECT id FROM listings WHERE article_no=?", (d["article_no"],)).fetchone()
        is_new = exists is None
        conn.execute(f"""
            INSERT INTO listings ({','.join(cols)}) VALUES ({','.join(':'+c for c in cols)})
            ON CONFLICT(article_no) DO UPDATE SET
              deposit=excluded.deposit, monthly_rent=excluded.monthly_rent, sale_price=excluded.sale_price,
              floor=excluded.floor, complex_name=excluded.complex_name, address=excluded.address,
              url=excluded.url, posted_at=excluded.posted_at, raw_json=excluded.raw_json
        """, d)
        row = conn.execute("SELECT * FROM listings WHERE article_no=?", (d["article_no"],)).fetchone()
    return dict(row), is_new


def upsert_market_deal(data: Dict[str, Any]) -> None:
    cols = ("house_type", "dong_code", "dong", "complex_name", "area", "deal_type",
            "deposit", "monthly_rent", "sale_amount", "deal_ym", "floor", "source")
    d = {c: data.get(c) for c in cols}
    d.setdefault("source", "molit")
    with _conn() as conn:
        conn.execute(f"""INSERT OR IGNORE INTO market_deals ({','.join(cols)})
                         VALUES ({','.join(':'+c for c in cols)})""", d)


def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6) -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute(
            """SELECT * FROM market_deals
               WHERE dong_code=? AND deal_type=?
                 AND (complex_name=? OR ? IS NULL OR complex_name IS NULL)
                 AND area BETWEEN ? AND ?
                 AND deal_ym >= strftime('%Y%m', 'now', ?)""",
            (dong_code, deal_type, complex_name, complex_name,
             (area or 0) - 5, (area or 0) + 5, f"-{int(months)} months"),
        ).fetchall()
    return [dict(r) for r in rows]


def upsert_listing_match(data: Dict[str, Any]) -> None:
    cols = ("listing_id", "category", "passed", "match_score", "jeonse_ratio", "safety_tier",
            "price_ratio", "valuation_tier", "market_median", "sample_size", "budget_ok",
            "regulation_flags", "reasons", "is_new")
    d = {c: data.get(c) for c in cols}
    d["regulation_flags"] = json.dumps(data.get("regulation_flags") or [], ensure_ascii=False)
    d["reasons"] = json.dumps(data.get("reasons") or [], ensure_ascii=False)
    with _conn() as conn:
        conn.execute(f"""
            INSERT INTO listing_matches ({','.join(cols)}) VALUES ({','.join(':'+c for c in cols)})
            ON CONFLICT(listing_id) DO UPDATE SET
              passed=excluded.passed, match_score=excluded.match_score,
              jeonse_ratio=excluded.jeonse_ratio, safety_tier=excluded.safety_tier,
              price_ratio=excluded.price_ratio, valuation_tier=excluded.valuation_tier,
              market_median=excluded.market_median, sample_size=excluded.sample_size,
              budget_ok=excluded.budget_ok, regulation_flags=excluded.regulation_flags,
              reasons=excluded.reasons, category=excluded.category
        """, d)


def get_listings(dong=None, deal_type=None, tier=None, matched_only=False, limit=50, offset=0) -> List[Dict[str, Any]]:
    sql = ("SELECT l.*, m.safety_tier, m.valuation_tier, m.category, m.match_score, m.passed "
           "FROM listings l LEFT JOIN listing_matches m ON m.listing_id=l.id WHERE 1=1")
    p = []
    if dong: sql += " AND l.dong=?"; p.append(dong)
    if deal_type: sql += " AND l.deal_type=?"; p.append(deal_type)
    if tier: sql += " AND (m.safety_tier=? OR m.valuation_tier=?)"; p += [tier, tier]
    if matched_only: sql += " AND m.passed=1"
    sql += " ORDER BY l.created_at DESC LIMIT ? OFFSET ?"; p += [limit, offset]
    with _conn() as conn:
        return [dict(r) for r in conn.execute(sql, p).fetchall()]


def get_listing_matches() -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute(
            "SELECT m.*, l.complex_name, l.dong, l.deal_type, l.deposit, l.sale_price, l.area_exclusive, l.url "
            "FROM listing_matches m JOIN listings l ON l.id=m.listing_id ORDER BY m.created_at DESC"
        ).fetchall()
    return [dict(r) for r in rows]


_TIER_ORDER = {"위험": 0, "고가": 0, "주의": 1, "시세": 1, "안전": 2, "저평가": 2, "보류": -1}


def get_unnotified_listing_matches(min_tier=None) -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute(
            "SELECT m.*, l.complex_name, l.dong, l.deal_type, l.deposit, l.monthly_rent, "
            "l.sale_price, l.area_exclusive, l.floor, l.url "
            "FROM listing_matches m JOIN listings l ON l.id=m.listing_id "
            "WHERE m.passed=1 AND m.notified_at IS NULL"
        ).fetchall()
    out = []
    for r in rows:
        d = dict(r)
        d["regulation_flags"] = json.loads(d.get("regulation_flags") or "[]")
        d["reasons"] = json.loads(d.get("reasons") or "[]")
        out.append(d)
    if min_tier:
        floor = _TIER_ORDER.get(min_tier, -1)
        out = [d for d in out if _TIER_ORDER.get(d.get("safety_tier") or d.get("valuation_tier"), -1) >= floor]
    return out


def mark_listings_notified(match_ids: List[int]) -> None:
    if not match_ids:
        return
    with _conn() as conn:
        conn.executemany(
            "UPDATE listing_matches SET notified_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
            [(i,) for i in match_ids],
        )


def save_listing_collect_log(new_count, total_count, naver_ok, error=None) -> None:
    with _conn() as conn:
        conn.execute(
            "INSERT INTO listing_collect_log (new_count,total_count,naver_ok,error) VALUES (?,?,?,?)",
            (new_count, total_count, 1 if naver_ok else 0, error),
        )


def get_last_listing_collect_log() -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        row = conn.execute("SELECT * FROM listing_collect_log ORDER BY id DESC LIMIT 1").fetchone()
    return dict(row) if row else None
