#!/usr/bin/env python3
"""facts/extract - acquire confirmed facts from the ops stack (2-source each).

  bin/facts/extract [--json] [--dry-run] [--ssh PATH] [--compose PATH]

The deduction rule: a fact is only stored under root=facts if it is backed by
>=2 independent sources. Sources here:

  S1 runtime  : docker ps (running containers)  or ~/.ssh/config (hosts)
  S2 declared : docker-compose files             or PLAN.md/README.md mentions

Extracted facts are written into var/kb.lbug (root=facts, confidence=confirmed).
--dry-run prints the proposed facts without touching the database.
"""
from __future__ import annotations

import json
import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "bin" / "tools"))

COMPOSE_FILES = [ROOT / "docker" / "compose.yaml", ROOT / "compose.yaml"]
DOC_MARKERS = ["README.md", "PLAN.md", "AGENTS.md"]
SSH_CONFIG = Path.home() / ".ssh" / "config"
REPO = "eSlider/2dph"


def read_docker_ps() -> list[str]:
    try:
        out = subprocess.run(
            ["docker", "ps", "--format", "{{.Names}}"],
            capture_output=True, text=True, timeout=10,
        )
        if out.returncode != 0:
            return []
        return [n.strip() for n in out.stdout.splitlines() if n.strip()]
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return []


def compose_files_in(dirname: Path) -> list[Path]:
    out = []
    for name in ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"):
        p = dirname / name
        if p.exists():
            out.append(p)
    return out


def container_compose_dir(name: str) -> Path | None:
    """Find the compose project dir a running container belongs to."""
    try:
        out = subprocess.run(
            ["docker", "inspect", name,
             "--format", "{{ index .Config.Labels \"com.docker.compose.project.working_dir\"}}"],
            capture_output=True, text=True, timeout=10,
        )
        if out.returncode != 0:
            return None
        path = out.stdout.strip()
        return Path(path) if path and path != "<no value>" else None
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return None


def read_compose_services(compose: Path) -> list[str]:
    if not compose.exists():
        return []
    try:
        import yaml
        return list(yaml.safe_load(compose.read_text()).get("services", {}).keys())
    except Exception:
        return []


def read_ssh_hosts(path: Path) -> list[str]:
    if not path.exists():
        return []
    hosts = []
    for line in path.read_text().splitlines():
        m = re.match(r"^\s*Host\s+(.+)$", line)
        if m:
            hosts.extend(h for h in m.group(1).split() if h not in ("*",))
    return hosts


def mentions(term: str, files: list[Path]) -> bool:
    for path in files:
        if not path.exists():
            continue
        try:
            if re.search(rf"\b{re.escape(term)}\b", path.read_text(), re.I):
                return True
        except OSError:
            continue
    return False


def build_facts() -> list[dict]:
    facts: list[dict] = []
    doc_files = [ROOT / m for m in DOC_MARKERS]

    running = read_docker_ps()
    # Pair each running container against its own compose file (2 independent
    # sources: runtime state docker ps × declared state compose).
    runtime_facts = 0
    for name in running:
        cdir = container_compose_dir(name)
        for cfile in compose_files_in(cdir) if cdir else []:
            if name in read_compose_services(cfile):
                facts.append({
                    "text": f"container '{name}' is running and declared in {cfile.name}",
                    "source": f"docker ps x compose:{cfile.name}",
                    "loc": f"{cfile}:{name}",
                    "how": "facts/extract",
                })
                runtime_facts += 1
                break
    if runtime_facts:
        print(f"facts/extract: paired {runtime_facts}/{len(running)} running containers to compose", file=sys.stderr)

    compose = [Path(p) for p in COMPOSE_FILES]
    compose_services = set()
    for c in compose:
        compose_services.update(read_compose_services(c))
    if compose_services and running:
        overlap = sorted(compose_services & set(running))
        for name in overlap:
            facts.append({
                "text": f"container '{name}' is running and declared in compose",
                "source": f"docker ps x {compose[0].name}",
                "loc": "docker ps; docker compose config",
                "how": "facts/extract",
            })

    hosts = read_ssh_hosts(SSH_CONFIG)
    for host in hosts:
        if mentions(host, doc_files):
            facts.append({
                "text": f"host '{host}' is configured in ~/.ssh/config and referenced in this repo",
                "source": f"ssh config x docs({', '.join(DOC_MARKERS)})",
                "loc": f"~/.ssh/config:{host}",
                "how": "facts/extract",
            })

    # Single-docker-container facts still need 2 sources: running + hostname hint
    for name in running:
        known_hosts = set(hosts)
        if not known_hosts:
            break
        # a running container name that also matches a configured host
        if name in known_hosts:
            facts.append({
                "text": f"container '{name}' is running and matches configured host '{name}'",
                "source": "docker ps x ssh config",
                "loc": f"docker ps:{name}; ~/.ssh/config:{name}",
                "how": "facts/extract",
            })
    return facts


def dedupe(facts: list[dict]) -> list[dict]:
    seen = set()
    out = []
    for f in facts:
        key = f["text"]
        if key in seen:
            continue
        seen.add(key)
        out.append(f)
    return out


def write_facts(facts: list[dict]) -> None:
    from kblib import VAR, connect, ensure_indexes, init_schema, upsert_leaf
    VAR.mkdir(exist_ok=True)
    db, conn = connect(VAR / "kb.lbug", read_only=False)
    init_schema(conn)
    from model2vec import StaticModel
    model = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
    for f in facts:
        emb = model.encode([f["text"]])[0].astype(float).tolist()
        upsert_leaf(conn, text=f["text"], root="facts", confidence="confirmed",
                    source=f["source"], source_rev=REPO, how=f["how"],
                    loc=f["loc"], type_="fact", embedding=emb)
    # Upsert-with-index is safe; never DROP+recreate (ghost catalog kills HNSW).
    ensure_indexes(conn)
    conn.close()
    db.close()


def main(argv: list[str]) -> int:
    global COMPOSE_FILES, SSH_CONFIG
    import argparse
    p = argparse.ArgumentParser(description="acquire confirmed facts from ops sources")
    p.add_argument("--json", action="store_true")
    p.add_argument("--dry-run", action="store_true")
    p.add_argument("--ssh", default=str(SSH_CONFIG))
    p.add_argument("--compose", action="append")
    a = p.parse_args(argv)

    if a.compose:
        COMPOSE_FILES = [Path(c) for c in a.compose]
    if a.ssh:
        SSH_CONFIG = Path(a.ssh)

    facts = dedupe(build_facts())
    if not a.dry_run and facts:
        write_facts(facts)

    out = {"count": len(facts), "facts": facts}
    if a.json:
        print(json.dumps(out, indent=2, ensure_ascii=False))
    else:
        from yamlout import to_yaml
        print(to_yaml(out))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))