#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from collections import Counter, defaultdict
from pathlib import Path


EXCLUDE_PREFIXES = ("Astro/", "notes/", "Brain/MOC/")
STOP_WORDS = {
    "の", "と", "は", "を", "に", "が", "で", "する", "した", "できる", "なる",
    "目次", "について", "こと", "ため", "よう", "使う", "使い", "運用", "実践",
    "方法", "構造", "基本", "理論", "知識", "思考", "学習", "技術", "管理",
    "導入", "改善", "設計", "整理", "活用", "実装", "運用", "価値", "最大",
    "Obsidian", "Brain", "Permanent", "Personal", "AI",
}
BRIDGE_HINT_WORDS = {"接続", "つなぐ", "橋渡し", "横断", "跨ぐ", "統合", "往復", "接合"}
METHOD_HINT_WORDS = {
    "練習", "方法", "メソッド", "手順", "実践", "反復", "ドリル", "訓練",
    "トレーニング", "型", "やり方", "習得", "上達", "運用", "テンプレート",
}
def find_vault_root(start: str, marker: str | None = None) -> Path:
    """Vault ルート探索。`.obsidian/` の存在で判定する。
    marker を指定すると、ディレクトリ名一致でも探索する（後方互換のフォールバック）。
    """
    path = Path(start).resolve()
    while True:
        if (path / ".obsidian").is_dir():
            return path
        if marker and path.name == marker:
            return path
        if path.parent == path:
            label = marker or ".obsidian/"
            raise RuntimeError(f"Vault root ({label}) が見つかりません: {start}")
        path = path.parent


def run_command(cmd: list[str]) -> str:
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, check=True)
        return result.stdout.strip()
    except subprocess.CalledProcessError as exc:
        stderr = exc.stderr.strip() or exc.stdout.strip()
        raise RuntimeError(f"command failed: {' '.join(cmd)} :: {stderr}") from exc


def is_excluded(path: str, include_moc: bool = False) -> bool:
    prefixes = EXCLUDE_PREFIXES if not include_moc else tuple(prefix for prefix in EXCLUDE_PREFIXES if prefix != "Brain/MOC/")
    return any(path.startswith(prefix) for prefix in prefixes)


def normalize_note_key(path: str) -> str:
    return Path(path).stem


def extract_terms(title: str) -> list[str]:
    clean = re.sub(r"[：:・/（）()「」『』【】\[\]、,\.]", " ", title)
    tokens = [token.strip() for token in clean.split() if token.strip()]
    return [token for token in tokens if token not in STOP_WORDS and len(token) > 1]


def compact_text(text: str) -> str:
    return re.sub(r"[\s：:・/（）()「」『』【】\[\]、,\.]", "", text)


def char_ngrams(text: str, size: int = 2) -> set[str]:
    compact = compact_text(text)
    if len(compact) <= size:
        return {compact} if compact else set()
    return {compact[index:index + size] for index in range(len(compact) - size + 1)}


def shorten_title(title: str, limit: int = 18) -> str:
    return title if len(title) <= limit else f"{title[:limit]}…"


def domain_of(path: str) -> str:
    if path.startswith("Brain/Personal/"):
        return "Personal"
    if path.startswith("Brain/Permanent/"):
        return "Permanent"
    if path.startswith("Brain/MOC/"):
        return "MOC"
    return "Other"


def list_recent_brain_notes(vault_root: Path, limit: int, offset: int = 0) -> list[str]:
    root = vault_root / "Brain"
    notes: list[tuple[int, str]] = []
    for path in root.rglob("*.md"):
        rel = str(path.relative_to(vault_root))
        if is_excluded(rel):
            continue
        notes.append((path.stat().st_mtime_ns, rel))
    notes.sort(key=lambda item: item[0], reverse=True)
    return [path for _, path in notes[offset: offset + limit]]


def score_seed(path: str, related_limit: int, cache: dict[tuple[str, int, bool], list[dict]]) -> dict:
    related = get_related(path, related_limit, cache)
    if not related:
        return {
            "path": path,
            "seed_score": 0.0,
            "related_count": 0,
            "strong_count": 0,
        }

    total_score = sum(max(float(item.get("score", 0.0)), 0.0) for item in related)
    strong_count = sum(1 for item in related if float(item.get("score", 0.0)) >= 0.55)
    seed_score = round(total_score + strong_count * 0.8, 2)
    return {
        "path": path,
        "seed_score": seed_score,
        "related_count": len(related),
        "strong_count": strong_count,
    }


def select_seed_notes(recent: list[str], related_limit: int, seed_limit: int, cache: dict[tuple[str, int, bool], list[dict]]) -> list[dict]:
    scored = [score_seed(path, related_limit=related_limit, cache=cache) for path in recent]
    scored.sort(key=lambda item: (-item["seed_score"], -item["strong_count"], -item["related_count"], item["path"]))
    filtered = [item for item in scored if item["related_count"] > 0]
    return filtered[:seed_limit]


def get_related(path: str, limit: int, cache: dict[tuple[str, int, bool], list[dict]], include_moc: bool = False) -> list[dict]:
    import time
    import tempfile

    cache_key = (path, limit, include_moc)
    if cache_key in cache:
        return cache[cache_key]

    tmp = tempfile.mktemp(suffix=".json")
    eval_code = (
        f'window.AtomicInsights.getRelatedNotes("{path}",{{limit:{limit}}})'
        f'.then(r=>{{require("fs").writeFileSync("{tmp}",JSON.stringify(r,null,2))}})'
        f'.catch(e=>{{require("fs").writeFileSync("{tmp}",JSON.stringify({{error:e.message}}))}});'
        f'"started"'
    )
    run_command(["obsidian", "eval", f"code={eval_code}"])
    time.sleep(8)

    results: list[dict] = []
    try:
        with open(tmp, "r", encoding="utf-8") as f:
            data = json.load(f)
        os.remove(tmp)
        if data.get("status") == "success":
            for item in data.get("results", []):
                rel_path = item.get("path", "")
                if not rel_path or is_excluded(rel_path, include_moc=include_moc):
                    continue
                results.append(item)
    except Exception:
        pass

    cache[cache_key] = results
    return results


def search_existing_mocs(query: str, limit: int = 5) -> list[str]:
    output = run_command(["obsidian", "search", f"query={query}", "path=Brain/MOC", f"limit={limit}"])
    if not output or output.strip() == "No matches found.":
        return []
    return [line.strip() for line in output.splitlines() if line.strip() and line.strip() != "No matches found."]


def load_root_index(vault_root: Path) -> list[dict]:
    roots: list[dict] = []
    for path in sorted((vault_root / "Brain" / "MOC").glob("*.md")):
        text = path.read_text(encoding="utf-8")
        # root MOC を frontmatter で判定する。後方互換のため "root" も受け入れる。
        moc_type_match = re.search(r"^moc_type:\s*(root|root)\s*$", text, re.MULTILINE)
        if moc_type_match is None:
            continue
        moc_type = moc_type_match.group(1).strip()

        description_match = re.search(r"^description:\s*(.+)$", text, re.MULTILINE)
        links = re.findall(r"\[\[([^\]]+)\]\]", text)
        link_keys = {normalize_note_key(link) for link in links}
        term_counter: Counter[str] = Counter()
        term_counter.update(extract_terms(path.stem))
        text_blob = path.stem
        if description_match:
            description = description_match.group(1)
            term_counter.update(extract_terms(description))
            text_blob += description

        roots.append({
            "title": path.stem,
            "path": str(path.relative_to(vault_root)),
            "moc_type": moc_type,
            "link_keys": link_keys,
            "terms": set(term_counter),
            "ngrams": char_ngrams(text_blob, size=2),
        })
    return roots


def load_moc_index(vault_root: Path, roots: list[dict]) -> list[dict]:
    root_titles = {root["title"] for root in roots}
    mocs: list[dict] = []
    for path in sorted((vault_root / "Brain" / "MOC").glob("*.md")):
        text = path.read_text(encoding="utf-8")
        description_match = re.search(r"^description:\s*(.+)$", text, re.MULTILINE)
        moc_type_match = re.search(r"^moc_type:\s*(.+)$", text, re.MULTILINE)
        title = path.stem
        description = description_match.group(1) if description_match else ""
        moc_type = moc_type_match.group(1).strip() if moc_type_match else ""
        links = re.findall(r"\[\[([^\]]+)\]\]", text)
        link_keys = {normalize_note_key(link) for link in links}
        term_counter: Counter[str] = Counter()
        term_counter.update(extract_terms(title))
        term_counter.update(extract_terms(description))
        text_blob = title + description

        parent_roots = []
        for root in roots:
            if title in root_titles and title == root["title"]:
                parent_roots.append(root["title"])
            elif normalize_note_key(title) in root["link_keys"]:
                parent_roots.append(root["title"])

        mocs.append({
            "title": title,
            "path": str(path.relative_to(vault_root)),
            "moc_type": moc_type,
            "description": description,
            "link_keys": link_keys,
            "terms": set(term_counter),
            "ngrams": char_ngrams(text_blob, size=2),
            "parent_roots": sorted(dict.fromkeys(parent_roots)),
        })
    return mocs


def select_parent_root_titles(roots: list[dict]) -> set[str]:
    root_map = {root["title"]: root for root in roots}
    root_titles = set(root_map)
    # root MOC を frontmatter で同定（複数あれば全部を BFS の起点にする）
    root_titles = {root["title"] for root in roots if root.get("moc_type") == "root"}
    if not root_titles:
        return set(root_titles)

    allowed: set[str] = set()
    queue = list(root_titles)
    seen = set(root_titles)

    while queue:
        current = queue.pop(0)
        current_root = root_map.get(current)
        if current_root is None:
            continue
        for title in root_titles:
            if title in seen:
                continue
            if normalize_note_key(title) in current_root["link_keys"] or title in current_root["link_keys"]:
                allowed.add(title)
                seen.add(title)
                queue.append(title)

    return allowed or (root_titles - root_titles)


def infer_moc_type(paths: list[str], term_counter: Counter[str]) -> str:
    domains = {domain_of(path) for path in paths}
    path_blob = " ".join(paths)
    if "Personal" in domains and "Permanent" in domains:
        return "bridge"
    if domains == {"Personal"}:
        return "narrative"
    if (METHOD_HINT_WORDS & set(term_counter)) or re.search(r"(Practice/|practice|練習|トレーニング|ドリル|方法)", path_blob, re.IGNORECASE):
        return "method"
    if BRIDGE_HINT_WORDS & set(term_counter):
        return "bridge"
    return "synthesis"


def infer_candidate_name(note_scores: dict[str, float]) -> tuple[str, list[str]]:
    weighted_terms: Counter[str] = Counter()
    for path, score in note_scores.items():
        for term in extract_terms(Path(path).stem):
            weighted_terms[term] += score

    common_terms = [term for term, _ in weighted_terms.most_common(4)]
    if len(common_terms) >= 2:
        return "・".join(common_terms[:2]), common_terms
    if len(common_terms) == 1:
        return common_terms[0], common_terms

    best_note = max(note_scores.items(), key=lambda item: item[1])[0]
    return Path(best_note).stem, extract_terms(Path(best_note).stem)


def build_packet_label(representative_notes: list[str]) -> str:
    anchors = [shorten_title(Path(path).stem) for path in representative_notes[:2]]
    return " × ".join(anchors)


def jaccard(left: set[str], right: set[str]) -> float:
    if not left or not right:
        return 0.0
    return len(left & right) / len(left | right)


def build_seed_cluster(seed: str, related_limit: int, second_hop_limit: int, direct_min_score: float, cache: dict[tuple[str, int, bool], list[dict]]) -> dict:
    note_scores: defaultdict[str, float] = defaultdict(float)
    seed_paths: set[str] = {seed}
    note_scores[seed] += 1.0

    one_hop = get_related(seed, related_limit, cache)
    high_confidence_paths: list[str] = []
    for item in one_hop:
        rel_path = item.get("path", "")
        if not rel_path:
            continue
        score = float(item.get("score", 0.0))
        note_scores[rel_path] += max(score, 0.15)
        if score >= direct_min_score:
            high_confidence_paths.append(rel_path)

    for neighbor in high_confidence_paths:
        note_scores[neighbor] += 0.2
        for item in get_related(neighbor, second_hop_limit, cache):
            rel_path = item.get("path", "")
            if not rel_path or rel_path == seed:
                continue
            score = float(item.get("score", 0.0))
            note_scores[rel_path] += max(score * 0.55, 0.08)

    note_scores = defaultdict(float, {path: score for path, score in note_scores.items() if score >= 0.22})
    seed_paths.update(high_confidence_paths)
    cluster_paths = set(note_scores)
    return {
        "seed": seed,
        "seed_paths": seed_paths,
        "note_scores": dict(note_scores),
        "cluster_paths": cluster_paths,
    }


def apply_recent_bias(
    note_scores: dict[str, float],
    recent_set: set[str],
    selected_seed_set: set[str],
    seed: str,
) -> dict[str, float]:
    biased: dict[str, float] = {}
    for path, score in note_scores.items():
        adjusted = score
        if path == seed:
            adjusted += 1.2
        if path in selected_seed_set:
            adjusted += 0.9
        elif path in recent_set:
            adjusted += 0.6
        biased[path] = adjusted
    return biased


def sort_notes_for_representation(
    note_scores: dict[str, float],
    recent_set: set[str],
    selected_seed_set: set[str],
) -> list[tuple[str, float]]:
    return sorted(
        note_scores.items(),
        key=lambda item: (
            -(1 if item[0] in selected_seed_set else 0),
            -(1 if item[0] in recent_set else 0),
            -item[1],
            item[0],
        ),
    )


def merge_clusters(clusters: list[dict], overlap_threshold: float) -> list[dict]:
    merged: list[dict] = []
    for cluster in sorted(clusters, key=lambda item: len(item["cluster_paths"]), reverse=True):
        target = None
        for existing in merged:
            if jaccard(cluster["cluster_paths"], existing["cluster_paths"]) >= overlap_threshold:
                target = existing
                break
        if target is None:
            merged.append({
                "seeds": [cluster["seed"]],
                "seed_paths": set(cluster["seed_paths"]),
                "cluster_paths": set(cluster["cluster_paths"]),
                "note_scores": dict(cluster["note_scores"]),
            })
            continue

        target["seeds"].append(cluster["seed"])
        target["seed_paths"].update(cluster["seed_paths"])
        target["cluster_paths"].update(cluster["cluster_paths"])
        for path, score in cluster["note_scores"].items():
            target["note_scores"][path] = target["note_scores"].get(path, 0.0) + score

    return merged


def suggest_parent_roots(
    candidate_paths: list[str],
    note_terms: list[str],
    roots: list[dict],
    mocs: list[dict],
    cache: dict[tuple[str, int, bool], list[dict]],
    allowed_parent_roots: set[str],
    limit: int,
) -> list[dict]:
    candidate_keys = {normalize_note_key(path) for path in candidate_paths}
    candidate_term_set = set(note_terms)
    candidate_ngrams: set[str] = set()
    for path in candidate_paths:
        candidate_ngrams.update(char_ngrams(Path(path).stem, size=2))

    related_moc_hits: defaultdict[str, float] = defaultdict(float)
    for path in candidate_paths[:4]:
        for item in get_related(path, 8, cache, include_moc=True):
            rel_path = item.get("path", "")
            if not rel_path.startswith("Brain/MOC/"):
                continue
            related_moc_hits[Path(rel_path).stem] += float(item.get("score", 0.0))

    root_score_map: defaultdict[str, float] = defaultdict(float)
    root_reason_map: defaultdict[str, list[str]] = defaultdict(list)
    root_path_map = {root["title"]: root["path"] for root in roots}

    for moc in mocs:
        link_overlap = len(candidate_keys & moc["link_keys"])
        term_overlap = len(candidate_term_set & moc["terms"])
        ngram_overlap = len(candidate_ngrams & moc["ngrams"])
        related_hit = related_moc_hits.get(moc["title"], 0.0)
        moc_score = link_overlap * 4 + term_overlap * 2 + (ngram_overlap / 20.0) + related_hit * 3
        if moc_score <= 0:
            continue

        target_roots = [moc["title"]] if moc["moc_type"] == "root" else moc["parent_roots"]
        if not target_roots:
            continue

        reasons = []
        if related_hit > 0:
            reasons.append(f"via related MOC {moc['title']}")
        elif link_overlap > 0 or term_overlap > 0:
            reasons.append(f"via MOC {moc['title']}")
        if link_overlap > 0:
            reasons.append(f"shared links {link_overlap}")
        if term_overlap > 0:
            reasons.append(f"shared terms {term_overlap}")

        for root_title in target_roots:
            if root_title not in allowed_parent_roots:
                continue
            root_score_map[root_title] += moc_score
            for reason in reasons:
                if reason not in root_reason_map[root_title]:
                    root_reason_map[root_title].append(reason)

    results = [
        {
            "title": title,
            "path": root_path_map.get(title, ""),
            "score": round(score, 2),
            "reason": "; ".join(root_reason_map[title][:3]),
        }
        for title, score in root_score_map.items()
        if root_reason_map[title]
    ]
    results.sort(key=lambda item: (-item["score"], item["title"]))
    return results[:limit]


def bridge_summary(paths: list[str], terms: list[str]) -> str:
    domains = sorted({domain_of(path) for path in paths if domain_of(path) in {"Personal", "Permanent"}})
    if len(domains) < 2:
        return ""
    connector = "・".join(terms[:2]) if terms else "共通テーマ"
    return f"{domains[0]} と {domains[1]} を {connector} で接続する候補"


def build_candidates(
    vault_root: Path,
    recent_limit: int,
    recent_offset: int,
    seed_limit: int,
    related_limit: int,
    second_hop_limit: int,
    min_notes: int,
    overlap_threshold: float,
    parent_root_limit: int,
    representative_limit: int,
    supporting_limit: int,
) -> list[dict]:
    recent = list_recent_brain_notes(vault_root, recent_limit, offset=recent_offset)
    related_cache: dict[tuple[str, int, bool], list[dict]] = {}
    roots = load_root_index(vault_root)
    mocs = load_moc_index(vault_root, roots)
    allowed_parent_roots = select_parent_root_titles(roots)
    selected_seeds = select_seed_notes(recent, related_limit=related_limit, seed_limit=seed_limit, cache=related_cache)
    recent_set = set(recent)
    selected_seed_set = {item["path"] for item in selected_seeds}

    raw_clusters = [
        build_seed_cluster(
            seed=seed_info["path"],
            related_limit=related_limit,
            second_hop_limit=second_hop_limit,
            direct_min_score=0.55,
            cache=related_cache,
        )
        for seed_info in selected_seeds
    ]

    merged_clusters = merge_clusters(raw_clusters, overlap_threshold=overlap_threshold)
    prepared_candidates: list[dict] = []
    seen_names: set[tuple[str, ...]] = set()

    for cluster in merged_clusters:
        cluster["note_scores"] = apply_recent_bias(
            cluster["note_scores"],
            recent_set=recent_set,
            selected_seed_set=selected_seed_set,
            seed=cluster["seeds"][0],
        )
        scored_notes = sort_notes_for_representation(
            cluster["note_scores"],
            recent_set=recent_set,
            selected_seed_set=selected_seed_set,
        )
        if len(scored_notes) < min_notes:
            continue

        candidate_name, common_terms = infer_candidate_name(dict(scored_notes[:8]))
        signature = tuple(representative_notes := [path for path, _ in scored_notes[:representative_limit]])
        if signature in seen_names:
            continue
        seen_names.add(signature)

        existing_mocs = search_existing_mocs(candidate_name, limit=3)
        all_notes = [path for path, _ in scored_notes[:supporting_limit]]
        recent_notes = [path for path in all_notes if path in recent_set][:3]
        packet_label = build_packet_label(representative_notes)
        moc_type = infer_moc_type(all_notes, Counter(common_terms))
        parent_roots = suggest_parent_roots(
            all_notes,
            common_terms,
            roots,
            mocs,
            related_cache,
            allowed_parent_roots,
            limit=parent_root_limit,
        )
        seeds = sorted(cluster["seeds"])
        domains = sorted({domain_of(path) for path in all_notes})

        prepared_candidates.append({
            "packet_label": packet_label,
            "moc_type": moc_type,
            "score": round(sum(score for _, score in scored_notes[:5]), 2),
            "recent_seeds": seeds[:3],
            "recent_notes": recent_notes,
            "representative_notes": representative_notes,
            "supporting_notes": all_notes,
            "cluster_paths": set(cluster["cluster_paths"]),
            "common_terms": common_terms[:4],
            "domains": domains,
            "existing_mocs": existing_mocs,
            "parent_roots": parent_roots,
            "bridge_summary": bridge_summary(all_notes, common_terms) if moc_type == "bridge" else "",
        })

    prepared_candidates.sort(key=lambda item: (-item["score"], item["packet_label"]))

    candidates: list[dict] = []
    used_paths: set[str] = set()
    for item in prepared_candidates:
        if item["existing_mocs"]:
            continue
        representative_overlap = len(set(item["representative_notes"]) & used_paths)
        support_overlap = len(set(item["supporting_notes"]) & used_paths)
        if representative_overlap >= 2:
            continue
        if support_overlap >= max(2, len(item["supporting_notes"]) // 2):
            continue
        used_paths.update(item["supporting_notes"])
        item.pop("cluster_paths", None)
        candidates.append(item)

    return candidates


def render_text(candidates: list[dict], limit: int) -> None:
    print(f"--- MOC candidate packets (top {limit}) ---")
    shown = 0
    for index, item in enumerate(candidates, start=1):
        if item["existing_mocs"]:
            continue

        print(f"[cluster_{index:02d}] [{item['moc_type']}] {item['packet_label']}  score={item['score']}")
        print(f"  seeds: {', '.join(item['recent_seeds'])}")
        if item["recent_notes"]:
            print(f"  recent notes: {', '.join(item['recent_notes'])}")
        print(f"  domains: {', '.join(item['domains'])}")
        if item["bridge_summary"]:
            print(f"  bridge: {item['bridge_summary']}")
        if item["parent_roots"]:
            root_text = ", ".join(f"{root['title']} ({root['reason']})" for root in item["parent_roots"])
            print(f"  parent roots: {root_text}")
        print("  representative notes:")
        for note in item["representative_notes"]:
            print(f"    - {note}")
        shown += 1
        if shown >= limit:
            break


def main() -> None:
    parser = argparse.ArgumentParser(description="最近ノート -> 2-hop -> 重複統合で MOC 候補パケットを作ります。")
    parser.add_argument("--recent-limit", type=int, default=24, help="最近ノートの探索範囲")
    parser.add_argument("--recent-offset", type=int, default=0, help="最近ノートの先頭スキップ件数")
    parser.add_argument("--seed-limit", type=int, default=8, help="1-hop 反応で選ぶ seed 数")
    parser.add_argument("--related-limit", type=int, default=5, help="1-hop の related 数")
    parser.add_argument("--second-hop-limit", type=int, default=3, help="2-hop の related 数")
    parser.add_argument("--min-notes", type=int, default=3, help="候補に必要な最小ノート数")
    parser.add_argument("--overlap-threshold", type=float, default=0.5, help="候補統合の Jaccard 閾値")
    parser.add_argument("--parent-root-limit", type=int, default=3, help="親 root 提案数")
    parser.add_argument("--representative-limit", type=int, default=3, help="代表ノート数")
    parser.add_argument("--supporting-limit", type=int, default=6, help="内部計算に使う上位ノート数")
    parser.add_argument("--limit", type=int, default=5, help="表示する候補数")
    parser.add_argument("--format", choices=("text", "json"), default="text", help="出力形式")
    args = parser.parse_args()

    vault_root = find_vault_root(os.path.dirname(os.path.abspath(__file__)))
    candidates = build_candidates(
        vault_root=vault_root,
        recent_limit=args.recent_limit,
        recent_offset=args.recent_offset,
        seed_limit=args.seed_limit,
        related_limit=args.related_limit,
        second_hop_limit=args.second_hop_limit,
        min_notes=args.min_notes,
        overlap_threshold=args.overlap_threshold,
        parent_root_limit=args.parent_root_limit,
        representative_limit=args.representative_limit,
        supporting_limit=args.supporting_limit,
    )

    if args.format == "json":
        print(json.dumps(candidates[: args.limit], ensure_ascii=False, indent=2))
        return

    render_text(candidates, limit=args.limit)


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        sys.exit(1)
