"""Decrypt Granola Windows cache and ingest meetings into Darsh's Vault."""
from __future__ import annotations

import base64
import ctypes
import ctypes.wintypes as wt
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

GRANOLA_DIR = Path(os.environ["APPDATA"]) / "Granola"
VAULT_MEETINGS = Path(r"C:\Users\darsh.shah\Documents\Darsh's Vault\work memos\meetings")
OUT_CACHE = Path(r"C:\Users\darsh.shah\AppData\Local\Temp\granola_cache_decrypted.json")


class DATA_BLOB(ctypes.Structure):
    _fields_ = [("cbData", wt.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]


def dpapi_decrypt(blob: bytes) -> bytes:
    src = DATA_BLOB(len(blob), ctypes.cast(ctypes.c_char_p(blob), ctypes.POINTER(ctypes.c_char)))
    out = DATA_BLOB()
    ok = ctypes.windll.crypt32.CryptUnprotectData(
        ctypes.byref(src), None, None, None, None, 0, ctypes.byref(out)
    )
    if not ok:
        raise OSError(f"CryptUnprotectData failed (GetLastError={ctypes.get_last_error()})")
    try:
        return ctypes.string_at(out.pbData, out.cbData)
    finally:
        ctypes.windll.kernel32.LocalFree(out.pbData)


def master_key(g: Path) -> bytes:
    local_state = json.loads((g / "Local State").read_text(encoding="utf-8"))
    encrypted_key = base64.b64decode(local_state["os_crypt"]["encrypted_key"])
    if encrypted_key[:5] != b"DPAPI":
        raise ValueError(f"unexpected key prefix: {encrypted_key[:5]!r}")
    return dpapi_decrypt(encrypted_key[5:])


def try_gcm(key: bytes, blob: bytes) -> bytes | None:
    hyps = [
        (3, 12),  # strip v10
        (4, 12),  # strip v10;
        (0, 12),  # raw nonce||ct||tag
    ]
    aes = AESGCM(key)
    for prefix_len, nonce_len in hyps:
        body = blob[prefix_len:]
        nonce, ct_tag = body[:nonce_len], body[nonce_len:]
        if len(nonce) < nonce_len or len(ct_tag) < 16:
            continue
        try:
            return aes.decrypt(nonce, ct_tag, None)
        except Exception:
            continue
    return None


def maybe_b64(pt: bytes) -> bytes:
    stripped = pt.strip()
    try:
        decoded = base64.b64decode(stripped, validate=True)
    except Exception:
        return pt
    if base64.b64encode(decoded) == stripped:
        return decoded
    return pt


def decrypt_granola_files(g: Path) -> tuple[dict, dict]:
    key = master_key(g)
    dek_pt = try_gcm(key, (g / "storage.dek").read_bytes())
    if dek_pt is None:
        raise RuntimeError("Could not decrypt storage.dek")
    data_key = maybe_b64(dek_pt)
    if len(data_key) not in (16, 32):
        # dek plaintext may be base64 string of the key
        data_key = maybe_b64(dek_pt)

    cache_pt = try_gcm(data_key, (g / "cache-v6.json.enc").read_bytes())
    if cache_pt is None:
        raise RuntimeError("Could not decrypt cache-v6.json.enc")
    cache = json.loads(cache_pt)

    supabase = {}
    enc = g / "supabase.json.enc"
    if enc.exists():
        sb_pt = try_gcm(data_key, enc.read_bytes())
        if sb_pt:
            supabase = json.loads(sb_pt)
    return cache, supabase


def pm_to_md(node) -> str:
    if node is None:
        return ""
    if isinstance(node, str):
        return node
    if isinstance(node, list):
        return "".join(pm_to_md(c) for c in node)
    if not isinstance(node, dict):
        return str(node)

    t = node.get("type", "")
    content = node.get("content") or []
    text = node.get("text", "") or ""
    marks = node.get("marks") or []

    if t == "text":
        s = text
        for m in marks:
            if not isinstance(m, dict):
                continue
            mt = m.get("type")
            if mt == "bold":
                s = f"**{s}**"
            elif mt == "italic":
                s = f"*{s}*"
            elif mt == "code":
                s = f"`{s}`"
            elif mt == "link":
                href = (m.get("attrs") or {}).get("href", "")
                s = f"[{s}]({href})"
        return s
    if t == "heading":
        level = (node.get("attrs") or {}).get("level", 2)
        return f"\n{'#' * level} {pm_to_md(content)}\n\n"
    if t == "paragraph":
        return pm_to_md(content) + "\n\n"
    if t in ("bulletList", "orderedList"):
        return pm_to_md(content) + "\n"
    if t == "listItem":
        inner = pm_to_md(content).rstrip()
        lines = inner.split("\n")
        out = "- " + (lines[0] if lines else "") + "\n"
        for line in lines[1:]:
            out += ("  " + line if line else "") + "\n"
        return out
    if t == "codeBlock":
        lang = (node.get("attrs") or {}).get("language", "") or ""
        return f"\n```{lang}\n{pm_to_md(content)}\n```\n\n"
    if t == "hardBreak":
        return "\n"
    if t == "blockquote":
        inner = pm_to_md(content).rstrip()
        return "\n".join("> " + l for l in inner.split("\n")) + "\n\n"
    return pm_to_md(content)


def safe_filename(s: str) -> str:
    s = s.replace("\u2014", "-").replace("\u2013", "-").replace("\u2018", "'").replace("\u2019", "'")
    s = s.replace("\u201c", '"').replace("\u201d", '"')
    s = re.sub(r'[<>:"/\\|?*]', "-", s)
    s = re.sub(r"\s+", " ", s).strip().rstrip(".")
    return s[:120] if s else "Untitled"


def parse_dt(val) -> datetime | None:
    if not val:
        return None
    if isinstance(val, (int, float)):
        # ms or s
        ts = val / 1000 if val > 1e12 else val
        try:
            return datetime.fromtimestamp(ts)
        except Exception:
            return None
    if isinstance(val, str):
        for fmt in (
            "%Y-%m-%dT%H:%M:%S.%fZ",
            "%Y-%m-%dT%H:%M:%SZ",
            "%Y-%m-%dT%H:%M:%S%z",
            "%Y-%m-%d",
        ):
            try:
                return datetime.strptime(val.replace("+00:00", "Z"), fmt.replace("%z", "Z") if val.endswith("Z") else fmt)
            except Exception:
                continue
        try:
            return datetime.fromisoformat(val.replace("Z", "+00:00")).replace(tzinfo=None)
        except Exception:
            return None
    return None


def people_links(people: list) -> list[str]:
    links = []
    for p in people or []:
        if isinstance(p, str):
            name = p
        elif isinstance(p, dict):
            name = p.get("name") or p.get("email") or p.get("display_name") or ""
        else:
            continue
        name = str(name).strip()
        if name:
            links.append(f"[[{name}]]")
    return links


def extract_documents(cache: dict) -> list[dict]:
    """Walk known cache shapes and return document dicts."""
    state = cache.get("cache", cache).get("state", cache.get("state", cache))
    entities = state.get("entities") or {}
    docs = []

    # Common shapes: entities.documents / documents by id
    for key in ("documents", "Document", "document"):
        bucket = entities.get(key)
        if isinstance(bucket, dict):
            for doc_id, doc in bucket.items():
                if isinstance(doc, dict):
                    d = dict(doc)
                    d.setdefault("id", doc_id)
                    docs.append(d)

    # Sometimes documents live at state.documents
    if not docs:
        bucket = state.get("documents")
        if isinstance(bucket, dict):
            for doc_id, doc in bucket.items():
                if isinstance(doc, dict):
                    d = dict(doc)
                    d.setdefault("id", doc_id)
                    docs.append(d)
        elif isinstance(bucket, list):
            docs.extend([d for d in bucket if isinstance(d, dict)])

    # Fallback: scan for objects that look like meetings
    if not docs:
        def walk(obj, path=""):
            found = []
            if isinstance(obj, dict):
                keys = set(obj.keys())
                if {"title", "id"} <= keys or {"title", "created_at"} <= keys:
                    if any(k in keys for k in ("notes", "transcript", "attendees", "calendar_event", "panels")):
                        found.append(obj)
                for k, v in obj.items():
                    found.extend(walk(v, f"{path}.{k}"))
            elif isinstance(obj, list):
                for i, v in enumerate(obj[:2000]):
                    found.extend(walk(v, f"{path}[{i}]"))
            return found
        docs = walk(cache)

    # de-dupe by id
    seen = set()
    unique = []
    for d in docs:
        did = d.get("id") or d.get("document_id")
        if did in seen:
            continue
        if did:
            seen.add(did)
        unique.append(d)
    return unique


def get_transcript_text(doc: dict, transcripts_index: dict) -> str:
    tid = doc.get("id") or doc.get("document_id")
    segs = None
    if tid and tid in transcripts_index:
        segs = transcripts_index[tid]
    if segs is None:
        segs = doc.get("transcript") or doc.get("transcript_segments")
    if not segs:
        return ""
    lines = []
    if isinstance(segs, str):
        return segs
    if isinstance(segs, dict):
        segs = segs.get("segments") or segs.get("utterances") or list(segs.values())
    if isinstance(segs, list):
        for seg in segs:
            if isinstance(seg, str):
                lines.append(seg)
            elif isinstance(seg, dict):
                speaker = seg.get("speaker") or seg.get("source") or seg.get("name") or ""
                text = seg.get("text") or seg.get("content") or seg.get("transcript") or ""
                if text:
                    lines.append(f"**{speaker}**: {text}" if speaker else text)
    return "\n\n".join(lines)


def notes_to_md(doc: dict) -> str:
    parts = []
    for key in ("notes", "private_notes", "enhanced_notes", "summary", "ai_summary"):
        val = doc.get(key)
        if not val:
            continue
        if isinstance(val, dict) and "type" in val:
            md = pm_to_md(val).strip()
            if md:
                parts.append(md)
        elif isinstance(val, str) and val.strip():
            parts.append(val.strip())
        elif isinstance(val, dict) and "content" in val:
            md = pm_to_md(val).strip()
            if md:
                parts.append(md)
    # panels
    panels = doc.get("panels") or doc.get("document_panels") or []
    if isinstance(panels, dict):
        panels = list(panels.values())
    for panel in panels:
        if not isinstance(panel, dict):
            continue
        title = panel.get("title") or panel.get("name") or "Panel"
        content = panel.get("content") or panel.get("panel") or panel.get("notes")
        if content:
            md = pm_to_md(content).strip() if isinstance(content, (dict, list)) else str(content).strip()
            if md:
                parts.append(f"## {title}\n\n{md}")
    return "\n\n".join(parts).strip()


def render_note(doc: dict, transcripts_index: dict) -> tuple[str, str]:
    title = (doc.get("title") or doc.get("name") or "Untitled meeting").strip()
    dt = (
        parse_dt(doc.get("created_at"))
        or parse_dt(doc.get("updated_at"))
        or parse_dt(doc.get("start_time"))
        or parse_dt((doc.get("calendar_event") or {}).get("start") if isinstance(doc.get("calendar_event"), dict) else None)
    )
    date_str = dt.strftime("%Y-%m-%d") if dt else "undated"
    file_date = dt.strftime("%m-%d-%Y") if dt else "undated"

    attendees = (
        doc.get("attendees")
        or doc.get("people")
        or (doc.get("calendar_event") or {}).get("attendees")
        or []
    )
    people = people_links(attendees)

    notes_md = notes_to_md(doc)
    transcript_md = get_transcript_text(doc, transcripts_index)

    front = [
        "---",
        "categories:",
        '  - "[[Meetings]]"',
        "type: []",
        f"date: {date_str}",
        "org:",
        "loc:",
        "people:",
    ]
    if people:
        for p in people:
            front.append(f"  - \"{p}\"")
    else:
        front.append("  - []")
    front.extend(
        [
            "topics: []",
            f"granola_id: {doc.get('id') or doc.get('document_id') or ''}",
            "source: granola",
            "---",
            "",
            f"# {title}",
            "",
        ]
    )
    body = []
    if notes_md:
        body.append("## Notes\n\n" + notes_md)
    if transcript_md:
        body.append("## Transcript\n\n" + transcript_md)
    if not body:
        body.append("_No notes or transcript found in local Granola cache._")

    content = "\n".join(front) + "\n\n".join(body).strip() + "\n"
    filename = f"{file_date} - {safe_filename(title)}.md"
    return filename, content


def main() -> int:
    inspect_only = "--inspect" in sys.argv
    print(f"Decrypting from {GRANOLA_DIR} ...")
    cache, supabase = decrypt_granola_files(GRANOLA_DIR)
    OUT_CACHE.write_text(json.dumps(cache, indent=2)[:5_000_000], encoding="utf-8")
    print(f"Wrote decrypt preview to {OUT_CACHE}")

    # summarize structure
    state = cache.get("cache", cache).get("state", {})
    entities = state.get("entities") or {}
    print("Top-level cache keys:", list(cache.keys())[:20])
    print("state keys:", list(state.keys())[:40])
    print("entities keys:", list(entities.keys())[:40])
    transcripts = state.get("transcripts") or entities.get("transcripts") or {}
    print("transcripts type/len:", type(transcripts).__name__, len(transcripts) if hasattr(transcripts, "__len__") else "?")

    docs = extract_documents(cache)
    print(f"Found {len(docs)} document-like objects")
    for d in docs[:5]:
        print(" -", d.get("id"), "|", (d.get("title") or d.get("name") or "")[:80], "| keys:", list(d.keys())[:15])

    if inspect_only:
        return 0

    VAULT_MEETINGS.mkdir(parents=True, exist_ok=True)
    written = 0
    skipped = 0
    for doc in docs:
        filename, content = render_note(doc, transcripts if isinstance(transcripts, dict) else {})
        path = VAULT_MEETINGS / filename
        # skip if same granola_id already present
        gid = str(doc.get("id") or "")
        already = False
        if path.exists():
            already = True
        elif gid:
            for existing in VAULT_MEETINGS.glob("*.md"):
                try:
                    text = existing.read_text(encoding="utf-8", errors="ignore")
                except Exception:
                    continue
                if f"granola_id: {gid}" in text:
                    already = True
                    break
        if already:
            skipped += 1
            continue
        path.write_text(content, encoding="utf-8")
        written += 1
        print(f"Wrote {path}")

    print(f"Done. wrote={written} skipped={skipped} total_docs={len(docs)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
