# -*- coding: utf-8 -*-
"""Generate a portable local Instagram media report with Apache ECharts.

The canonical parent JSON remains the source of truth.  The generator writes:

* ``<folder>.html`` beside the canonical ``<folder>.json``;
* a portable JSON snapshot and legacy ``index.htm`` inside the media folder;
* a local ``echarts.min.js`` runtime inside the media folder.

No network request is required when the report is opened with ``file://``.
"""

from __future__ import annotations

import html
import json
import os
import re
import shutil
import tempfile
import urllib.parse
from collections import Counter, defaultdict
from datetime import datetime
from typing import Any, Dict, Iterable, List, Sequence, Tuple

REPORT_VERSION = 4
ECHARTS_VERSION = "6.1.0-simple"
VIDEO_EXTS = {".mp4", ".mov", ".webm", ".m4v"}
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".avif"}


def _now_text() -> str:
    return datetime.now().astimezone().isoformat(timespec="seconds")


def _read_json(path: str) -> Dict[str, Any]:
    with open(path, "r", encoding="utf-8") as handle:
        data = json.load(handle)
    if not isinstance(data, dict):
        raise ValueError("report_json_root_not_object")
    return data


def _atomic_write_text(path: str, text: str) -> None:
    folder = os.path.dirname(os.path.abspath(path))
    os.makedirs(folder, exist_ok=True)
    fd, temp_path = tempfile.mkstemp(prefix=".ig_report_", suffix=".tmp", dir=folder)
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
            handle.write(text)
        os.replace(temp_path, path)
    except Exception:
        try:
            os.unlink(temp_path)
        except OSError:
            pass
        raise


def _atomic_write_json(path: str, data: Dict[str, Any]) -> None:
    _atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2))


def _safe_file_name(value: Any, fallback: str = "instagram") -> str:
    text = str(value or "").strip()
    text = re.sub(r"[<>:\\/?*|\x00-\x1f]", "_", text)
    text = text.strip(" ._")
    return text or fallback


def _safe_http_url(value: Any) -> str:
    text = str(value or "").strip()
    try:
        parsed = urllib.parse.urlparse(text)
    except Exception:
        return ""
    if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
        return ""
    return text


def _quote_rel(path: Any) -> str:
    text = str(path or "").strip().replace("\\", "/")
    if not text:
        return ""
    parts = [urllib.parse.quote(part, safe="-_.~()[]") for part in text.split("/") if part not in {"", "."}]
    return "/".join(parts)


def _clean_owner(value: Any) -> str:
    text = str(value or "").strip().lstrip("@").strip()
    if not text or text.lower() in {"instagram", "unknown", "none"}:
        return ""
    return text


def _int_value(value: Any, default: int = 0) -> int:
    try:
        return int(float(str(value or "0").replace(",", "")))
    except (TypeError, ValueError):
        return int(default)


def _profile_url(item: Dict[str, Any], owner: str) -> str:
    explicit = _safe_http_url(item.get("profile_url"))
    if explicit:
        return explicit
    return f"https://www.instagram.com/{urllib.parse.quote(owner, safe='._')}/" if owner else ""


def _post_url(item: Dict[str, Any]) -> str:
    for key in ("post_url", "media_url", "page_url", "current_url", "target_url", "href"):
        value = _safe_http_url(item.get(key))
        if value:
            return value
    shortcode = str(item.get("shortcode") or "").strip()
    if shortcode:
        kind = "reel" if str(item.get("asset_kind") or "").lower() in {"video", "mp4"} else "p"
        return f"https://www.instagram.com/{kind}/{urllib.parse.quote(shortcode, safe='-_')}/"
    return ""


def _item_date(item: Dict[str, Any]) -> str:
    for key in ("downloaded_at", "first_downloaded_at", "collected_at", "metadata_updated_at", "last_seen_at", "upload_at"):
        raw = str(item.get(key) or "").strip()
        if not raw:
            continue
        match = re.match(r"(\d{4}-\d{2}-\d{2})", raw)
        if match:
            return match.group(1)
        match = re.match(r"(\d{4})(\d{2})(\d{2})", raw)
        if match:
            return "-".join(match.groups())
    return "날짜없음"


def _display_time(item: Dict[str, Any]) -> str:
    """Return the Instagram publish time only; local save time belongs to the file metadata."""
    return str(item.get("upload_at") or item.get("published_at") or "").strip()


def _sort_field(item: Dict[str, Any]) -> str:
    value = str(item.get("sort_field") or "").strip()
    if value:
        return value
    sort = item.get("sort") if isinstance(item.get("sort"), dict) else {}
    return str(sort.get("field") or "미지정").strip() or "미지정"


def _normalize_items(doc: Dict[str, Any], media_folder: str) -> List[Dict[str, Any]]:
    raw_items = doc.get("items")
    if not isinstance(raw_items, dict):
        return []
    result: List[Dict[str, Any]] = []
    for key, raw in raw_items.items():
        if not isinstance(raw, dict):
            continue
        item = dict(raw)
        item["_key"] = str(key)
        kind = str(item.get("asset_kind") or "").lower()
        is_metadata = kind == "metadata" or bool(item.get("metadata_only")) or (
            bool(_int_value(item.get("metadata_collected"), 0)) and not str(item.get("file_name") or item.get("file_path") or "").strip()
        )
        file_name = os.path.basename(str(item.get("file_name") or item.get("file_path") or "").strip())
        if not file_name and not is_metadata:
            continue
        item["file_name"] = file_name
        item["_exists"] = bool(file_name and os.path.exists(os.path.join(media_folder, file_name)))
        ext = os.path.splitext(file_name)[1].lower()
        if is_metadata:
            item["_kind"] = "metadata"
        elif ext in VIDEO_EXTS or kind in {"video", "mp4"}:
            item["_kind"] = "video"
        elif ext in IMAGE_EXTS or kind in {"image", "jpg", "jpeg"}:
            item["_kind"] = "image"
        else:
            item["_kind"] = "file"
        owner = _clean_owner(item.get("owner_username") or item.get("ig_account_id") or item.get("account_name"))
        item["_owner"] = owner
        item["_profile_url"] = _profile_url(item, owner)
        item["_post_url"] = _post_url(item)
        item["_date"] = _item_date(item)
        item["_display_time"] = _display_time(item)
        item["_sort_field"] = _sort_field(item)
        result.append(item)
    result.sort(
        key=lambda value: (
            str(value.get("_owner") or ""),
            str(value.get("_date") or ""),
            str(value.get("_sort_field") or ""),
            _int_value(value.get("rank_no"), 0),
            str(value.get("shortcode") or ""),
            str(value.get("file_name") or ""),
        ),
        reverse=True,
    )
    return result

def _post_identity(item: Dict[str, Any]) -> str:
    owner = str(item.get("_owner") or "")
    shortcode = str(item.get("shortcode") or "").strip()
    if shortcode:
        return f"{owner}|{shortcode}"
    stem = str(item.get("filename_stem") or os.path.splitext(str(item.get("file_name") or ""))[0])
    stem = re.sub(r"_(?:No\d+|No없음)(?:_\d+)?$", "", stem, flags=re.I)
    return f"{owner}|{stem or item.get('_key') or item.get('file_name')}"


def _group_posts(items: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
    grouped: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
    for item in items:
        grouped[_post_identity(item)].append(item)
    posts: List[Dict[str, Any]] = []
    for identity, assets in grouped.items():
        assets = sorted(assets, key=lambda value: (_int_value(value.get("media_index"), 0), str(value.get("file_name") or "")))
        owner = next((str(asset.get("_owner") or "") for asset in assets if asset.get("_owner")), "")
        shortcode = next((str(asset.get("shortcode") or "") for asset in assets if asset.get("shortcode")), "")
        post_url = next((str(asset.get("_post_url") or "") for asset in assets if asset.get("_post_url")), "")
        profile_url = next((str(asset.get("_profile_url") or "") for asset in assets if asset.get("_profile_url")), "")
        source_url = next((_safe_http_url(asset.get("source_url")) for asset in assets if _safe_http_url(asset.get("source_url"))), "")
        thumbnail_url = next((_safe_http_url(asset.get("thumbnail_url")) for asset in assets if _safe_http_url(asset.get("thumbnail_url"))), "")
        rank_no = max((_int_value(asset.get("rank_no"), 0) for asset in assets), default=0)
        rank_label = next((str(asset.get("rank_label") or "") for asset in assets if asset.get("rank_label")), "") or (f"No{rank_no:03d}" if rank_no else "No없음")
        direct_video: List[str] = []
        direct_image: List[str] = []
        for asset in assets:
            direct = asset.get("direct_media_urls") if isinstance(asset.get("direct_media_urls"), dict) else {}
            for raw in direct.get("video") or []:
                url = _safe_http_url(raw)
                if url and url not in direct_video:
                    direct_video.append(url)
            for raw in direct.get("image") or []:
                url = _safe_http_url(raw)
                if url and url not in direct_image:
                    direct_image.append(url)
            video_url = _safe_http_url(asset.get("video_url"))
            image_url = _safe_http_url(asset.get("thumbnail_url"))
            if video_url and video_url not in direct_video:
                direct_video.append(video_url)
            if image_url and image_url not in direct_image:
                direct_image.append(image_url)
        local_file_count = sum(1 for asset in assets if asset.get("_kind") != "metadata" and asset.get("_exists"))
        post = {
            "identity": identity,
            "owner": owner,
            "shortcode": shortcode,
            "post_url": post_url,
            "profile_url": profile_url,
            "source_url": source_url,
            "thumbnail_url": thumbnail_url,
            "date": max((str(asset.get("_date") or "날짜없음") for asset in assets), default="날짜없음"),
            "display_time": max((str(asset.get("_display_time") or "") for asset in assets), default=""),
            "sort_field": next((str(asset.get("_sort_field") or "") for asset in assets if asset.get("_sort_field")), "미지정"),
            "rank_no": rank_no,
            "rank_label": rank_label,
            "views": max((_int_value(asset.get("views"), 0) for asset in assets), default=0),
            "likes": max((_int_value(asset.get("likes"), 0) for asset in assets), default=0),
            "comments": max((_int_value(asset.get("comments"), 0) for asset in assets), default=0),
            "reposts": max((_int_value(asset.get("reposts") or asset.get("shares"), 0) for asset in assets), default=0),
            "duplicate_seen_count": max((_int_value(asset.get("duplicate_seen_count"), 0) for asset in assets), default=0),
            "metadata_collected": any(bool(_int_value(asset.get("metadata_collected"), 0)) for asset in assets),
            "video_requested": any(bool(_int_value(asset.get("video_requested"), 0)) for asset in assets),
            "video_downloaded": any(bool(_int_value(asset.get("video_downloaded"), 0)) for asset in assets),
            "image_requested": any(bool(_int_value(asset.get("image_requested"), 0)) for asset in assets),
            "image_downloaded": any(bool(_int_value(asset.get("image_downloaded"), 0)) for asset in assets),
            "metadata_only": all(asset.get("_kind") == "metadata" for asset in assets),
            "direct_media_urls": {"video": direct_video, "image": direct_image},
            "local_file_count": local_file_count,
            "assets": assets,
        }
        posts.append(post)
    posts.sort(
        key=lambda post: (
            str(post.get("owner") or ""),
            str(post.get("date") or ""),
            str(post.get("sort_field") or ""),
            _int_value(post.get("rank_no"), 0),
            str(post.get("shortcode") or ""),
        ),
        reverse=True,
    )
    return posts

def _local_image(post: Dict[str, Any]) -> str:
    for asset in post.get("assets") or []:
        if asset.get("_kind") == "image" and asset.get("_exists"):
            return str(asset.get("file_name") or "")
    video_stems = [os.path.splitext(str(asset.get("file_name") or ""))[0] for asset in post.get("assets") or [] if asset.get("_kind") == "video"]
    folder = str(post.get("_media_folder") or "")
    for stem in video_stems:
        for ext in (".jpg", ".jpeg", ".png", ".webp", ".avif"):
            candidate = stem + ext
            if folder and os.path.exists(os.path.join(folder, candidate)):
                return candidate
    return ""


def _metric_json(post: Dict[str, Any]) -> str:
    payload = [
        {"name": "조회수", "value": _int_value(post.get("views"), 0)},
        {"name": "좋아요", "value": _int_value(post.get("likes"), 0)},
        {"name": "댓글", "value": _int_value(post.get("comments"), 0)},
        {"name": "공유/리포스트", "value": _int_value(post.get("reposts"), 0)},
    ]
    return html.escape(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), quote=True)


def _asset_links(post: Dict[str, Any], media_prefix: str) -> str:
    links: List[str] = []
    local_index = 0
    for asset in post.get("assets") or []:
        if asset.get("_kind") == "metadata":
            continue
        file_name = str(asset.get("file_name") or "")
        if not file_name:
            continue
        local_index += 1
        href = _quote_rel(media_prefix + file_name)
        kind = "MP4" if asset.get("_kind") == "video" else "JPG" if asset.get("_kind") == "image" else "FILE"
        missing = " (없음)" if not asset.get("_exists") else ""
        links.append(f'<a href="{html.escape(href, quote=True)}" target="_blank">{kind} 파일 {local_index}{html.escape(missing)}</a>')
    direct = post.get("direct_media_urls") if isinstance(post.get("direct_media_urls"), dict) else {}
    for index, url in enumerate(direct.get("video") or [], start=1):
        safe = _safe_http_url(url)
        if safe:
            links.append(f'<a href="{html.escape(safe, quote=True)}" target="_blank" rel="noopener noreferrer">MP4 직접링크 {index}</a>')
    for index, url in enumerate(direct.get("image") or [], start=1):
        safe = _safe_http_url(url)
        if safe:
            links.append(f'<a href="{html.escape(safe, quote=True)}" target="_blank" rel="noopener noreferrer">JPG 직접링크 {index}</a>')
    if post.get("metadata_only"):
        links.append('<span class="metadata-note">파일 미저장 · metadata 수집</span>')
    return " ".join(links)

def _post_card_html(post: Dict[str, Any], media_prefix: str, chart_index: int) -> str:
    owner = str(post.get("owner") or "")
    shortcode = str(post.get("shortcode") or "")
    rank_label = str(post.get("rank_label") or "No없음")
    post_url = _safe_http_url(post.get("post_url"))
    source_url = _safe_http_url(post.get("source_url"))
    profile_url = _safe_http_url(post.get("profile_url"))
    local_image = _local_image(post)
    image_src = _quote_rel(media_prefix + local_image) if local_image else _safe_http_url(post.get("thumbnail_url"))
    first_asset = next(iter(post.get("assets") or []), {})
    first_local = _quote_rel(media_prefix + str(first_asset.get("file_name") or ""))
    click_url = post_url or source_url or first_local or "#"
    if image_src:
        preview = f'<a class="thumb-link" href="{html.escape(click_url, quote=True)}" target="_blank" rel="noopener noreferrer"><img loading="lazy" src="{html.escape(image_src, quote=True)}" alt="{html.escape(rank_label)} {html.escape(shortcode)}"></a>'
    elif first_asset.get("_kind") == "video" and first_asset.get("_exists"):
        preview = f'<video controls preload="metadata"><source src="{html.escape(first_local, quote=True)}">이 브라우저에서 동영상을 재생할 수 없습니다.</video>'
    else:
        preview = f'<a class="preview-empty" href="{html.escape(click_url, quote=True)}" target="_blank" rel="noopener noreferrer">섬네일 없음<br>게시물 열기</a>'
    external: List[str] = []
    if profile_url:
        external.append(f'<a href="{html.escape(profile_url, quote=True)}" target="_blank" rel="noopener noreferrer">@{html.escape(owner)} 계정</a>')
    if post_url:
        external.append(f'<a href="{html.escape(post_url, quote=True)}" target="_blank" rel="noopener noreferrer">Instagram 게시물</a>')
    if source_url and source_url != post_url:
        external.append(f'<a href="{html.escape(source_url, quote=True)}" target="_blank" rel="noopener noreferrer">원본 미디어 링크</a>')
    asset_links = _asset_links(post, media_prefix)
    if asset_links:
        external.append(asset_links)
    search_text = " ".join([owner, shortcode, rank_label, str(post.get("sort_field") or ""), str(post.get("date") or "")]).lower()
    duplicate = _int_value(post.get("duplicate_seen_count"), 0)
    duplicate_text = f"중복 확인 {duplicate}회" if duplicate else ("metadata 수집" if post.get("metadata_only") else "신규 저장")
    status_parts = ["metadata 완료" if post.get("metadata_collected") else "metadata 미확인"]
    status_parts.append("MP4 저장" if post.get("video_downloaded") else ("MP4 요청" if post.get("video_requested") else "MP4 미선택"))
    status_parts.append("JPG 저장" if post.get("image_downloaded") else ("JPG 요청" if post.get("image_requested") else "JPG 미선택"))
    status_text = " · ".join(status_parts)
    chart_id = f"post-chart-{chart_index}"
    return f'''
<article class="post-card" data-account="{html.escape(owner, quote=True)}" data-sort="{html.escape(str(post.get('sort_field') or '미지정'), quote=True)}" data-date="{html.escape(str(post.get('date') or '날짜없음'), quote=True)}" data-search="{html.escape(search_text, quote=True)}">
  <div class="preview">{preview}</div>
  <div class="card-body">
    <div class="badges"><span>{html.escape(rank_label)}</span><span>{html.escape(str(post.get('sort_field') or '미지정'))}</span><span>{html.escape(str(post.get('date') or '날짜없음'))}</span><span>{html.escape(duplicate_text)}</span></div>
    <div class="save-status">{html.escape(status_text)}</div>
    <h3>{html.escape('@' + owner if owner else '계정 미확인')} · {html.escape(shortcode or 'shortcode 없음')}</h3>
    <div class="links">{' '.join(external)}</div>
    <dl>
      <dt>조회수</dt><dd>{_int_value(post.get('views'), 0):,}</dd>
      <dt>좋아요</dt><dd>{_int_value(post.get('likes'), 0):,}</dd>
      <dt>댓글</dt><dd>{_int_value(post.get('comments'), 0):,}</dd>
      <dt>공유/리포스트</dt><dd>{_int_value(post.get('reposts'), 0):,}</dd>
      <dt>게시 시각</dt><dd>{html.escape(str(post.get('display_time') or '-'))}</dd>
      <dt>로컬 파일</dt><dd>{_int_value(post.get('local_file_count'), 0)}개</dd>
    </dl>
    <div id="{chart_id}" class="post-chart" data-chart="{_metric_json(post)}"><span>차트 준비 중</span></div>
  </div>
</article>'''


def _summary(doc: Dict[str, Any], items: Sequence[Dict[str, Any]], posts: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
    by_account = Counter(str(post.get("owner") or "계정없음") for post in posts)
    by_sort = Counter(str(post.get("sort_field") or "미지정") for post in posts)
    by_date = Counter(str(post.get("date") or "날짜없음") for post in posts)
    return {
        "version": REPORT_VERSION,
        "generated_at": _now_text(),
        "post_count": len(posts),
        "asset_count": sum(1 for item in items if item.get("_kind") != "metadata" and item.get("_exists")),
        "account_count": len(by_account),
        "by_account": dict(sorted(by_account.items())),
        "by_sort": dict(sorted(by_sort.items())),
        "by_date": dict(sorted(by_date.items())),
    }


def _select_options(values: Iterable[str], all_label: str) -> str:
    unique = sorted({str(value or "") for value in values if str(value or "")})
    return f'<option value="">{html.escape(all_label)}</option>' + "".join(
        f'<option value="{html.escape(value, quote=True)}">{html.escape(value)}</option>' for value in unique
    )


def _safe_script_json(value: Any) -> str:
    text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
    return text.replace("</", "<\\/").replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")



def _report_account_name(doc: Dict[str, Any], posts: Sequence[Dict[str, Any]]) -> str:
    owners = sorted({_clean_owner(post.get("owner")) for post in posts if _clean_owner(post.get("owner"))})
    if len(owners) == 1:
        return owners[0]
    folder_owner = _clean_owner(doc.get("folder_name"))
    if folder_owner and folder_owner.lower() not in {"instagram", "merged", "all", "전체"}:
        return folder_owner
    return ""

def _build_html(
    doc: Dict[str, Any],
    items: List[Dict[str, Any]],
    posts: List[Dict[str, Any]],
    media_folder: str,
    media_prefix: str,
    json_href: str,
    echarts_src: str,
) -> str:
    for post in posts:
        post["_media_folder"] = media_folder
    folder_name = str(doc.get("folder_name") or "Instagram")
    title = f"{folder_name} 통합 Instagram 보관함"
    summary = doc.get("report_summary") if isinstance(doc.get("report_summary"), dict) else _summary(doc, items, posts)
    cards = "".join(_post_card_html(post, media_prefix, index) for index, post in enumerate(posts, start=1))
    account_options = _select_options((str(post.get("owner") or "계정없음") for post in posts), "전체 계정")
    sort_options = _select_options((str(post.get("sort_field") or "미지정") for post in posts), "전체 sort by")
    date_options = _select_options((str(post.get("date") or "날짜없음") for post in posts), "전체 날짜")
    generated_at = str(summary.get("generated_at") or _now_text())
    report_account = _report_account_name(doc, posts)
    full_view_url = "http://127.0.0.1:8780/"
    if report_account:
        full_view_url += "?account=" + urllib.parse.quote(report_account, safe="._")
    report_data = {
        "folder_name": folder_name,
        "generated_at": generated_at,
        "summary": summary,
        "posts": [
            {
                "owner": post.get("owner"),
                "shortcode": post.get("shortcode"),
                "date": post.get("date"),
                "sort_field": post.get("sort_field"),
                "rank_no": post.get("rank_no"),
                "views": post.get("views"),
                "likes": post.get("likes"),
                "comments": post.get("comments"),
                "reposts": post.get("reposts"),
                "post_url": post.get("post_url"),
                "metadata_collected": post.get("metadata_collected"),
                "video_requested": post.get("video_requested"),
                "video_downloaded": post.get("video_downloaded"),
                "image_requested": post.get("image_requested"),
                "image_downloaded": post.get("image_downloaded"),
                "direct_media_urls": post.get("direct_media_urls"),
            }
            for post in posts
        ],
    }
    return f'''<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(title)}</title>
<style>
:root{{--bg:#0f1115;--panel:#171a21;--line:#2b303b;--text:#f2f4f8;--muted:#aab2c0;--accent:#6aa7ff}}
*{{box-sizing:border-box}} body{{margin:0;background:var(--bg);color:var(--text);font-family:Arial,"Noto Sans KR",sans-serif}} a{{color:#8fc0ff;text-decoration:none}} a:hover{{text-decoration:underline}}
header{{position:sticky;top:0;z-index:5;background:rgba(15,17,21,.97);border-bottom:1px solid var(--line);padding:16px 22px}} h1{{margin:0 0 8px;font-size:23px}} .sub{{color:var(--muted);font-size:13px}}
.summary{{display:grid;grid-template-columns:repeat(auto-fit,minmax(145px,1fr));gap:10px;padding:18px 22px}} .metric{{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px}} .metric strong{{display:block;font-size:24px}}
.controls{{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:8px;padding:0 22px 16px}} select,input{{background:#202530;color:var(--text);border:1px solid #394150;border-radius:8px;padding:10px 12px;min-width:0}}
.grid-cards{{display:grid;grid-template-columns:repeat(auto-fill,320px);justify-content:start;gap:16px;padding:0 22px 30px}} .post-card{{width:320px;background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden}} .preview{{width:320px;aspect-ratio:1/1;background:#08090c;display:flex;align-items:center;justify-content:center;overflow:hidden}} .preview img,.preview video{{width:100%;height:100%}} .preview img{{object-fit:cover}} .preview video{{object-fit:contain}} .preview-empty{{text-align:center;color:var(--muted)}} .card-body{{padding:14px}} .badges{{display:flex;gap:7px;flex-wrap:wrap}} .badges span{{background:#252b36;border-radius:999px;padding:4px 8px;font-size:12px}} h3{{font-size:15px;word-break:break-all}} .links{{display:flex;gap:9px;flex-wrap:wrap;font-size:13px}} .metadata-note,.save-status{{color:var(--muted);font-size:12px}} .save-status{{margin-top:8px}} dl{{display:grid;grid-template-columns:105px 1fr;gap:6px;font-size:13px}} dt{{color:var(--muted)}} dd{{margin:0;word-break:break-all}} .post-chart{{height:210px;margin-top:12px;border-top:1px solid var(--line);padding-top:8px}} .post-chart>span{{display:block;padding:18px;color:var(--muted);text-align:center}}
.empty{{padding:24px;color:var(--muted)}} footer{{padding:18px 22px;color:var(--muted);border-top:1px solid var(--line)}} [hidden]{{display:none!important}}
@media(max-width:760px){{.controls{{grid-template-columns:1fr 1fr}}.grid-cards{{grid-template-columns:minmax(0,320px);padding-left:12px;padding-right:12px}}.post-card,.preview{{width:min(100%,320px)}}}}
</style>
</head>
<body>
<header><h1>{html.escape(title)}</h1><div class="sub">계정 · sort by · 날짜별 필터 · <a href="{html.escape(full_view_url, quote=True)}" target="_blank" rel="noopener noreferrer">전체보기</a> · <a href="{html.escape(_quote_rel(json_href), quote=True)}" target="_blank">JSON 열기</a> · 완전 로컬 ECharts</div></header>
<section class="summary">
<div class="metric"><span>게시물</span><strong>{_int_value(summary.get('post_count'), len(posts))}</strong></div>
<div class="metric"><span>로컬 파일</span><strong>{_int_value(summary.get('asset_count'), len(items))}</strong></div>
<div class="metric"><span>계정</span><strong>{_int_value(summary.get('account_count'), 0)}</strong></div>
<div class="metric"><span>수집 날짜</span><strong>{len(summary.get('by_date') or {})}</strong></div>
</section>
<section class="controls">
<select id="filter-account" aria-label="계정 필터">{account_options}</select>
<select id="filter-sort" aria-label="sort by 필터">{sort_options}</select>
<select id="filter-date" aria-label="날짜 필터">{date_options}</select>
<input id="search" type="search" placeholder="계정, shortcode, No순위 검색">
</section>
<main id="post-grid" class="grid-cards">{cards or '<p class="empty">저장된 게시물이 없습니다.</p>'}</main>
<footer>생성 시각: {html.escape(generated_at)} · 오프라인 보고서 v{REPORT_VERSION} · ECharts {html.escape(ECHARTS_VERSION)}</footer>
<script id="report-data" type="application/json">{_safe_script_json(report_data)}</script>
<script src="{html.escape(_quote_rel(echarts_src), quote=True)}"></script>
<script>
(()=>{{
  const cards=[...document.querySelectorAll('.post-card')];
  const account=document.getElementById('filter-account');
  const sort=document.getElementById('filter-sort');
  const date=document.getElementById('filter-date');
  const search=document.getElementById('search');
  const rendered=new WeakSet();
  function compact(v){{v=Number(v||0);if(v>=1e9)return (v/1e9).toFixed(1)+'B';if(v>=1e6)return (v/1e6).toFixed(1)+'M';if(v>=1e3)return (v/1e3).toFixed(1)+'K';return String(v);}}
  function draw(el){{
    if(rendered.has(el))return; rendered.add(el);
    let metrics=[]; try{{metrics=JSON.parse(el.dataset.chart||'[]')}}catch(_){{}}
    if(!window.echarts){{el.innerHTML='<span>ECharts 로컬 파일을 불러오지 못했습니다. 수치는 위 표에서 확인할 수 있습니다.</span>';return;}}
    el.innerHTML='';
    const chart=window.echarts.init(el,null,{{renderer:'canvas'}});
    chart.setOption({{
      animation:false, grid:{{left:84,right:18,top:8,bottom:26}}, tooltip:{{trigger:'axis',axisPointer:{{type:'shadow'}},valueFormatter:compact}},
      xAxis:{{type:'value',axisLabel:{{formatter:compact}},splitLine:{{lineStyle:{{color:'#303642'}}}}}},
      yAxis:{{type:'category',data:metrics.map(x=>x.name),axisLabel:{{color:'#c9d1dc'}}}},
      series:[{{type:'bar',data:metrics.map(x=>Number(x.value||0)),barMaxWidth:22,label:{{show:true,position:'right',formatter:p=>compact(p.value)}}}}]
    }});
    el.__chart=chart;
  }}
  const observer=('IntersectionObserver' in window)?new IntersectionObserver(entries=>entries.forEach(entry=>{{if(entry.isIntersecting){{draw(entry.target);observer.unobserve(entry.target);}}}}),{{rootMargin:'240px'}}):null;
  document.querySelectorAll('.post-chart').forEach(el=>observer?observer.observe(el):draw(el));
  function apply(){{
    const q=(search.value||'').trim().toLowerCase();
    cards.forEach(card=>{{
      const visible=(!account.value||card.dataset.account===account.value)&&(!sort.value||card.dataset.sort===sort.value)&&(!date.value||card.dataset.date===date.value)&&(!q||(card.dataset.search||'').includes(q));
      card.hidden=!visible;
      if(visible&&!observer){{const el=card.querySelector('.post-chart');if(el)draw(el);}}
    }});
  }}
  [account,sort,date].forEach(el=>el.addEventListener('change',apply)); search.addEventListener('input',apply);
  window.addEventListener('resize',()=>document.querySelectorAll('.post-chart').forEach(el=>{{if(el.__chart)el.__chart.resize();}}));
  apply();
}})();
</script>
</body></html>'''


def _copy_echarts_runtime(media_folder: str) -> str:
    source = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "echarts.min.js")
    if not os.path.exists(source) or os.path.getsize(source) < 10000:
        raise FileNotFoundError("echarts_runtime_not_found")
    target = os.path.join(media_folder, "echarts.min.js")
    copy_required = not os.path.exists(target) or os.path.getsize(target) != os.path.getsize(source)
    if copy_required:
        temp_target = target + ".tmp"
        shutil.copyfile(source, temp_target)
        os.replace(temp_target, target)
    return target


def generate_account_report(json_path: str, media_folder: str = "") -> Dict[str, Any]:
    """Generate same-name parent HTML plus portable legacy report assets."""
    try:
        json_path = os.path.abspath(str(json_path or ""))
        if not json_path or not os.path.exists(json_path):
            return {"ok": False, "error": "report_json_not_found", "json_path": json_path}
        doc = _read_json(json_path)
        folder_name = _safe_file_name(doc.get("folder_name") or os.path.splitext(os.path.basename(json_path))[0])
        media_folder = os.path.abspath(str(media_folder or os.path.join(os.path.dirname(json_path), folder_name)))
        os.makedirs(media_folder, exist_ok=True)
        items = _normalize_items(doc, media_folder)
        posts = _group_posts(items)
        summary = _summary(doc, items, posts)
        canonical_html_path = os.path.splitext(json_path)[0] + ".html"
        snapshot_name = f"{folder_name}.json"
        snapshot_path = os.path.join(media_folder, snapshot_name)
        legacy_report_path = os.path.join(media_folder, "index.htm")
        echarts_path = _copy_echarts_runtime(media_folder)

        report_info = {
            "version": REPORT_VERSION,
            "html_file": os.path.basename(canonical_html_path),
            "legacy_html_file": f"{folder_name}/index.htm",
            "portable_json_file": f"{folder_name}/{snapshot_name}",
            "echarts_file": f"{folder_name}/echarts.min.js",
            "echarts_version": ECHARTS_VERSION,
            "source_policy": "canonical_json_is_source_of_truth",
            "updated_at": summary["generated_at"],
        }
        canonical_doc = dict(doc)
        canonical_doc["report"] = report_info
        canonical_doc["report_summary"] = summary
        _atomic_write_json(json_path, canonical_doc)

        snapshot_doc = dict(canonical_doc)
        snapshot_doc["report_snapshot"] = {
            "version": REPORT_VERSION,
            "generated_at": summary["generated_at"],
            "html_file": "index.htm",
            "canonical_html_file": f"../{os.path.basename(canonical_html_path)}",
            "portable_media_folder": ".",
            "echarts_file": "echarts.min.js",
            "source_policy": "canonical_json_is_source_of_truth",
        }
        if os.path.normcase(snapshot_path) != os.path.normcase(json_path):
            _atomic_write_json(snapshot_path, snapshot_doc)
        else:
            snapshot_path = json_path

        canonical_html = _build_html(
            canonical_doc,
            items,
            posts,
            media_folder,
            media_prefix=f"{folder_name}/",
            json_href=os.path.basename(json_path),
            echarts_src=f"{folder_name}/echarts.min.js",
        )
        legacy_html = _build_html(
            snapshot_doc,
            items,
            posts,
            media_folder,
            media_prefix="",
            json_href=snapshot_name,
            echarts_src="echarts.min.js",
        )
        _atomic_write_text(canonical_html_path, canonical_html)
        _atomic_write_text(legacy_report_path, legacy_html)
        return {
            "ok": True,
            "report_path": canonical_html_path,
            "legacy_report_path": legacy_report_path,
            "snapshot_json_path": snapshot_path,
            "echarts_path": echarts_path,
            "item_count": len(items),
            "post_count": len(posts),
            "media_folder": media_folder,
            "report_version": REPORT_VERSION,
        }
    except Exception as exc:
        return {
            "ok": False,
            "error": "report_generation_failed",
            "detail": str(exc),
            "json_path": str(json_path or ""),
            "media_folder": str(media_folder or ""),
        }


def rebuild_account_reports(instagram_root: str) -> Dict[str, Any]:
    """Rebuild every canonical account report under _StDown/instagram."""
    root = os.path.abspath(str(instagram_root or ""))
    if not root or not os.path.isdir(root):
        return {"ok": False, "error": "instagram_root_not_found", "root": root, "requested_count": 0, "success_count": 0, "failed_count": 0, "results": []}
    json_paths = sorted(
        path for path in (os.path.join(root, name) for name in os.listdir(root))
        if os.path.isfile(path) and path.lower().endswith(".json")
    )
    results: List[Dict[str, Any]] = []
    success_count = 0
    for json_path in json_paths:
        folder_name = os.path.splitext(os.path.basename(json_path))[0]
        media_folder = os.path.join(root, folder_name)
        result = generate_account_report(json_path, media_folder)
        result = dict(result or {})
        result["json_path"] = json_path
        results.append(result)
        if result.get("ok"):
            success_count += 1
    requested_count = len(json_paths)
    failed_count = requested_count - success_count
    return {"ok": failed_count == 0, "root": root, "requested_count": requested_count, "success_count": success_count, "failed_count": failed_count, "results": results, "error": "" if failed_count == 0 else "report_rebuild_partial_failure"}


__all__ = ["REPORT_VERSION", "ECHARTS_VERSION", "generate_account_report", "rebuild_account_reports"]
