"""Storage and capture helpers for g-gremlin memory."""

from __future__ import annotations

import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Sequence

from g_gremlin import __version__ as CLI_VERSION

from . import query
from . import render

SCHEMA_ENTRY = "GremlinMemoryEntry/v1"
SCHEMA_INDEX = "GremlinMemoryIndex/v1"
RETENTION_LIMIT = 200
NOTICE_SENTINEL = ".gitignore_notice_seen"

# Update this list when new high-value operator workflows are added to the CLI.
COVERED_COMMANDS = {
    "inspect",
    "validate",
    "sfdc:preview-push",
    "repair:analyze",
    "repair:fix",
    "rescue",
    "chain",
    "recipe:build-account-dim",
    "recipe:detect-open-renewals",
    "recipe:attach-products",
    "recipe:resolve-unmatched",
    "recipe:enrich-from-sfdc",
}

# Update this when command workflows or operator guidance changes in a meaningful way.
NEXT_STEP_RULES_VERSION = 1


def is_covered_command(command_id: str | None) -> bool:
    return str(command_id or "").strip() in COVERED_COMMANDS


def memory_root(project_root: Path | None = None) -> Path:
    base = project_root or Path.cwd()
    return base / ".g_gremlin" / "memory"


def load_index(project_root: Path | None = None) -> dict[str, Any]:
    root = memory_root(project_root)
    path = _index_json_path(root)
    if not path.exists():
        return _empty_index()
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return _empty_index()
    if not isinstance(payload, dict):
        return _empty_index()
    entries = payload.get("entries")
    if not isinstance(entries, list):
        payload["entries"] = []
    payload.setdefault("$schema", SCHEMA_INDEX)
    payload.setdefault("retention_limit", RETENTION_LIMIT)
    payload.setdefault("updated_at", _now_iso())
    return payload


def load_entry(entry_id: str, project_root: Path | None = None) -> dict[str, Any] | None:
    path = _entry_json_path(memory_root(project_root), entry_id)
    if not path.exists():
        return None
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None
    return payload if isinstance(payload, dict) else None


def capture_invocation(
    *,
    project_root: Path | None,
    command_id: str | None,
    command_path: str,
    correlation_id: str | None,
    duration_ms: int,
    exit_code: int,
    enabled: bool,
    agentic_result: Mapping[str, Any] | None,
    payload: Mapping[str, Any] | None,
    exc: BaseException | None,
    args_redacted: Sequence[str] | None = None,
) -> dict[str, str] | None:
    if not enabled or not is_covered_command(command_id):
        return None
    if int(exit_code) == 2:
        return None
    project = project_root or Path.cwd()
    root = memory_root(project)
    _ensure_dirs(root)
    index = load_index(project)
    existing_entries = [entry for entry in index.get("entries", []) if isinstance(entry, dict)]
    entry = _build_entry(
        project_root=project,
        command_id=str(command_id),
        command_path=command_path,
        correlation_id=correlation_id,
        duration_ms=duration_ms,
        exit_code=exit_code,
        agentic_result=agentic_result,
        payload=payload,
        exc=exc,
        existing_entries=existing_entries,
        args_redacted=args_redacted or [],
    )
    entry_id = str(entry.get("entry_id") or "").strip()
    if not entry_id:
        return None
    _write_entry_json(root, entry)
    _write_entry_page(root, entry)
    retained_entries, affected_commands = _update_index_with_entry(root, project, index, entry)
    for affected_command in affected_commands:
        _write_command_page(root, affected_command, retained_entries)
    _delete_stale_command_pages(root, retained_entries)
    _maybe_emit_gitignore_notice(root, project)
    return {
        "entry_id": entry_id,
        "entry_path": _path_text(_entry_json_path(root, entry_id), project),
        "page_path": _path_text(_entry_page_path(root, entry_id), project),
    }


def rebuild_memory(
    project_root: Path | None = None,
    *,
    with_ai: bool = False,
) -> dict[str, Any]:
    root = memory_root(project_root)
    project = project_root or Path.cwd()
    _ensure_dirs(root)
    entry_paths = sorted(_entries_dir(root).glob("*.json"))
    entries: list[dict[str, Any]] = []
    for path in entry_paths:
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
        except Exception:
            continue
        if isinstance(payload, dict):
            entries.append(payload)
    entries.sort(key=lambda row: str(row.get("created_at") or ""), reverse=True)
    retained = entries[:RETENTION_LIMIT]
    pruned = entries[RETENTION_LIMIT:]
    for entry in pruned:
        _delete_entry_artifacts(root, str(entry.get("entry_id") or ""))
    provider = _resolve_provider_or_none() if with_ai else None
    for entry in retained:
        entry["related_entry_ids"] = _compute_related_entry_ids(
            entry,
            [candidate for candidate in retained if candidate.get("entry_id") != entry.get("entry_id")],
        )
        if provider is not None:
            ai_summary = _generate_ai_summary(entry, provider)
            if ai_summary:
                entry["ai_summary"] = ai_summary
        _write_entry_json(root, entry)
        _write_entry_page(root, entry)
    payload = _build_index_payload(root, project, retained)
    _write_index(root, payload)
    for command_id in _all_command_ids(retained):
        _write_command_page(root, command_id, retained)
    _delete_stale_command_pages(root, retained)
    return {
        "rebuilt_entries": len(retained),
        "pruned_entries": len(pruned),
        "with_ai": bool(with_ai),
        "provider_configured": provider is not None,
    }


def _build_entry(
    *,
    project_root: Path,
    command_id: str,
    command_path: str,
    correlation_id: str | None,
    duration_ms: int,
    exit_code: int,
    agentic_result: Mapping[str, Any] | None,
    payload: Mapping[str, Any] | None,
    exc: BaseException | None,
    existing_entries: Sequence[Mapping[str, Any]],
    args_redacted: Sequence[str],
) -> dict[str, Any]:
    payload_map = _copy_mapping(payload)
    created_at = _now_iso()
    status = query.normalize_status(
        payload_map.get("status")
        or (agentic_result or {}).get("status")
        or ("success" if int(exit_code) == 0 else "fail")
    )
    entry_id = _sanitize_entry_id(
        str(payload_map.get("entry_id") or correlation_id or "").strip() or _fallback_entry_id(created_at)
    )
    inputs = _normalize_artifact_items(payload_map.get("inputs"), project_root, include_metadata=True)
    outputs = _normalize_artifact_items(payload_map.get("outputs"), project_root)
    artifacts = _normalize_artifact_items(payload_map.get("artifacts"), project_root)
    issues = query.normalize_text_list(payload_map.get("issues"))
    key_facts = query.normalize_text_list(payload_map.get("key_facts"))
    summary = str(payload_map.get("summary") or "").strip() or _default_summary(
        command_id, status, agentic_result, exc, issues, key_facts
    )
    next_steps = query.normalize_text_list(payload_map.get("suggested_next_steps")) or _derive_next_steps(
        command_id,
        status,
        summary,
        issues,
        key_facts,
        outputs,
    )
    entry = {
        "$schema": SCHEMA_ENTRY,
        "entry_id": entry_id,
        "created_at": created_at,
        "command_id": command_id,
        "status": status,
        "title": str(payload_map.get("title") or "").strip() or _default_title(command_id, status),
        "summary": summary,
        "tags": _normalize_tags(payload_map.get("tags"), command_id),
        "inputs": inputs,
        "outputs": outputs,
        "artifacts": artifacts,
        "key_facts": key_facts,
        "issues": issues,
        "suggested_next_steps": next_steps,
        "related_entry_ids": _compute_related_entry_ids(
            {
                "command_id": command_id,
                "inputs": inputs,
                "outputs": outputs,
                "artifacts": artifacts,
                "created_at": created_at,
            },
            existing_entries,
        ),
        "provenance": {
            "command": command_path,
            "command_id": command_id,
            "correlation_id": correlation_id,
            "cli_version": CLI_VERSION,
            "duration_ms": int(duration_ms),
            "exit_code": int(exit_code),
            "args_redacted": list(args_redacted),
            "command_args_sanitized": _normalize_mapping(payload_map.get("args")),
            "next_step_rules_version": NEXT_STEP_RULES_VERSION,
        },
    }
    if agentic_result:
        entry["provenance"]["agentic_status"] = str(agentic_result.get("status") or "")
    if exc is not None:
        entry["provenance"]["error"] = str(exc)
    ai_summary = str(payload_map.get("ai_summary") or "").strip()
    if ai_summary:
        entry["ai_summary"] = ai_summary
    return entry


def _default_title(command_id: str, status: str) -> str:
    return f"{command_id.replace(':', ' ')} {status}"


def _default_summary(
    command_id: str,
    status: str,
    agentic_result: Mapping[str, Any] | None,
    exc: BaseException | None,
    issues: Sequence[str],
    key_facts: Sequence[str],
) -> str:
    if exc is not None:
        return f"{command_id} ended with {status}: {exc}"
    if issues:
        return f"{command_id} ended with {status}; {len(issues)} issue(s) recorded."
    if key_facts:
        return f"{command_id} completed with {status}; {key_facts[0]}"
    result = (agentic_result or {}).get("result")
    if isinstance(result, Mapping):
        for key in ("summary", "message"):
            if result.get(key):
                return str(result.get(key))
    return f"{command_id} completed with status {status}."


def _derive_next_steps(
    command_id: str,
    status: str,
    summary: str,
    issues: Sequence[str],
    key_facts: Sequence[str],
    outputs: Sequence[Mapping[str, Any]],
) -> list[str]:
    # Keep these rules explicit and easy to revise as CLI workflows evolve.
    blob = " ".join([summary, *issues, *key_facts]).lower()
    output_path = ""
    for item in outputs:
        path = str(item.get("path") or "").strip()
        if path:
            output_path = path
            break
    if command_id == "validate":
        if "duplicate" in blob:
            return ["Run the dedup workflow for the source CSV, then rerun g-gremlin validate."]
        if "row-count" in blob or "row count" in blob or "drift" in blob:
            return ["Run g-gremlin diff against the prior CSV to inspect row-count drift."]
        if status != "success":
            return ["Fix the source CSV issues and rerun g-gremlin validate."]
        return ["Proceed with the next step, and rerun g-gremlin validate after major edits."]
    if command_id == "inspect":
        if any(term in blob for term in ("formula", "warning", "error", "fixable", "lint")):
            return ["Run g-gremlin lint or g-gremlin repair analyze for the flagged sheet range."]
        return ["Run g-gremlin validate on the exported CSV before any writeback or push."]
    if command_id == "sfdc:preview-push":
        if "not found" in blob:
            return ["Run g-gremlin sfdc audit on the source CSV to resolve missing Salesforce records."]
        if "change" in blob:
            return ["Review the generated .md/.json preview outputs before proceeding to the downstream update step."]
        return ["No changes were detected; recheck the CSV field mapping before pushing."]
    if command_id == "repair:analyze":
        return ["Run g-gremlin repair fix or g-gremlin rescue --dry-run on the analyzed range."]
    if command_id in {"repair:fix", "rescue"}:
        return ["Run g-gremlin lint and review the latest snapshot or diff before further edits."]
    if command_id == "chain" or command_id.startswith("recipe:"):
        if output_path:
            return [f"Run g-gremlin validate --csv {output_path} before any downstream writeback or push."]
        return ["Run g-gremlin validate on the produced CSV before any downstream writeback or push."]
    return ["Review the retained artifacts and rerun the command after addressing the recorded issues."]


def _update_index_with_entry(
    root: Path,
    project_root: Path,
    index: Mapping[str, Any],
    entry: Mapping[str, Any],
) -> tuple[list[dict[str, Any]], set[str]]:
    current = [row for row in index.get("entries", []) if isinstance(row, dict)]
    entry_record = _build_index_record(root, project_root, entry)
    entry_id = str(entry_record.get("entry_id") or "")
    current = [row for row in current if str(row.get("entry_id") or "") != entry_id]
    current.append(entry_record)
    current.sort(key=lambda row: str(row.get("created_at") or ""), reverse=True)

    retained = current[:RETENTION_LIMIT]
    pruned = current[RETENTION_LIMIT:]
    affected_commands = {str(entry_record.get("command_id") or "")}
    for row in pruned:
        affected_commands.add(str(row.get("command_id") or ""))
        _delete_entry_artifacts(root, str(row.get("entry_id") or ""))

    payload = {
        "$schema": SCHEMA_INDEX,
        "updated_at": _now_iso(),
        "retention_limit": RETENTION_LIMIT,
        "entries": retained,
    }
    _write_index(root, payload)
    return retained, {cmd for cmd in affected_commands if cmd}


def _build_index_payload(root: Path, project_root: Path, entries: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
    records = [_build_index_record(root, project_root, entry) for entry in entries]
    records.sort(key=lambda row: str(row.get("created_at") or ""), reverse=True)
    return {
        "$schema": SCHEMA_INDEX,
        "updated_at": _now_iso(),
        "retention_limit": RETENTION_LIMIT,
        "entries": records[:RETENTION_LIMIT],
    }


def _build_index_record(root: Path, project_root: Path, entry: Mapping[str, Any]) -> dict[str, Any]:
    record = _copy_mapping(entry)
    entry_id = str(record.get("entry_id") or "")
    if entry_id:
        record["entry_path"] = _path_text(_entry_json_path(root, entry_id), project_root)
        record["page_path"] = _path_text(_entry_page_path(root, entry_id), project_root)
    return record


def _compute_related_entry_ids(
    entry: Mapping[str, Any],
    existing_entries: Sequence[Mapping[str, Any]],
) -> list[str]:
    current_paths = _collect_paths(entry)
    current_artifacts = _collect_paths(entry, keys=("artifacts",))
    family = _command_family(str(entry.get("command_id") or ""))
    scored: list[tuple[int, str, str]] = []
    for candidate in existing_entries:
        candidate_id = str(candidate.get("entry_id") or "").strip()
        if not candidate_id:
            continue
        score = 0
        candidate_paths = _collect_paths(candidate)
        candidate_artifacts = _collect_paths(candidate, keys=("artifacts",))
        if current_paths and candidate_paths:
            overlap = current_paths & candidate_paths
            if overlap:
                score += 4 + len(overlap)
        if current_artifacts and candidate_artifacts:
            overlap = current_artifacts & candidate_artifacts
            if overlap:
                score += 2 + len(overlap)
        if family and _command_family(str(candidate.get("command_id") or "")) == family:
            score += 1
        if score <= 0:
            continue
        scored.append((score, str(candidate.get("created_at") or ""), candidate_id))
    scored.sort(key=lambda row: (-row[0], row[1], row[2]), reverse=False)
    return [entry_id for _, _, entry_id in scored[:5]]


def _collect_paths(entry: Mapping[str, Any], *, keys: Sequence[str] = ("inputs", "outputs", "artifacts")) -> set[str]:
    paths: set[str] = set()
    for key in keys:
        value = entry.get(key)
        if not isinstance(value, Sequence):
            continue
        for item in value:
            if isinstance(item, Mapping):
                path_text = str(item.get("path") or "").strip()
                if path_text:
                    paths.add(path_text)
    return paths


def _command_family(command_id: str) -> str:
    parts = [part for part in command_id.split(":") if part]
    if not parts:
        return ""
    return parts[0]


def _normalize_tags(value: Any, command_id: str) -> list[str]:
    tags = query.normalize_text_list(value)
    root = command_id.split(":", 1)[0]
    if root and root not in tags:
        tags.append(root)
    if command_id not in tags:
        tags.append(command_id)
    deduped: list[str] = []
    seen: set[str] = set()
    for tag in tags:
        cleaned = tag.strip().lower()
        if not cleaned or cleaned in seen:
            continue
        seen.add(cleaned)
        deduped.append(cleaned)
    return deduped


def _normalize_mapping(value: Any) -> dict[str, Any]:
    if not isinstance(value, Mapping):
        return {}
    return {str(key): query.normalize_scalar(item) for key, item in value.items()}


def _normalize_artifact_items(value: Any, project_root: Path, *, include_metadata: bool = False) -> list[dict[str, Any]]:
    items: list[dict[str, Any]] = []
    if value is None:
        return items
    raw_items = list(value) if isinstance(value, (list, tuple, set)) else [value]
    metadata = _collect_metadata(raw_items, project_root) if include_metadata else {}
    for raw in raw_items:
        item = _normalize_artifact_item(raw, project_root, metadata)
        if item:
            items.append(item)
    return items


def _normalize_artifact_item(
    raw: Any,
    project_root: Path,
    metadata: Mapping[str, Mapping[str, Any]] | None = None,
) -> dict[str, Any] | None:
    metadata = metadata or {}
    if isinstance(raw, (str, Path)):
        item = {"path": _path_text(Path(raw), project_root)}
    elif isinstance(raw, Mapping):
        item = {str(key): query.normalize_scalar(value) for key, value in raw.items()}
        if item.get("path"):
            item["path"] = _path_text(Path(str(item["path"])), project_root)
    else:
        return None
    path_text = str(item.get("path") or "").strip()
    if path_text and path_text in metadata:
        item.setdefault("mtime", metadata[path_text].get("mtime"))
        item.setdefault("sha256", metadata[path_text].get("sha256"))
    if not path_text and not item.get("label"):
        return None
    return item


def _collect_metadata(raw_items: Sequence[Any], project_root: Path) -> dict[str, dict[str, Any]]:
    paths: list[Path] = []
    for raw in raw_items:
        if isinstance(raw, Path):
            paths.append(raw if raw.is_absolute() else project_root / raw)
        elif isinstance(raw, str):
            path = Path(raw)
            paths.append(path if path.is_absolute() else project_root / path)
        elif isinstance(raw, Mapping) and raw.get("path"):
            path = Path(str(raw.get("path")))
            paths.append(path if path.is_absolute() else project_root / path)
    if not paths:
        return {}
    try:
        from g_gremlin import provenance as provenance_helpers

        entries = provenance_helpers.collect_input_metadata(paths)
    except Exception:
        return {}
    result: dict[str, dict[str, Any]] = {}
    for entry in entries:
        raw_path = entry.get("path")
        if not raw_path:
            continue
        result[_path_text(Path(str(raw_path)), project_root)] = {
            "mtime": entry.get("mtime"),
            "sha256": entry.get("sha256"),
        }
    return result


def _write_index(root: Path, payload: Mapping[str, Any]) -> None:
    _index_json_path(root).write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8")
    _index_md_path(root).write_text(render.render_index_markdown(payload), encoding="utf-8")


def _write_entry_json(root: Path, entry: Mapping[str, Any]) -> None:
    entry_id = str(entry.get("entry_id") or "")
    if not entry_id:
        return
    _entry_json_path(root, entry_id).write_text(json.dumps(entry, indent=2, ensure_ascii=True), encoding="utf-8")


def _write_entry_page(root: Path, entry: Mapping[str, Any]) -> None:
    entry_id = str(entry.get("entry_id") or "")
    if not entry_id:
        return
    _entry_page_path(root, entry_id).write_text(render.render_entry_markdown(entry), encoding="utf-8")


def _write_command_page(root: Path, command_id: str, entries: Sequence[Mapping[str, Any]]) -> None:
    command_entries = [entry for entry in entries if str(entry.get("command_id") or "") == command_id]
    path = _command_page_path(root, command_id)
    if not command_entries:
        if path.exists():
            path.unlink()
        return
    path.write_text(render.render_command_page_markdown(command_id, command_entries), encoding="utf-8")


def _delete_stale_command_pages(root: Path, entries: Sequence[Mapping[str, Any]]) -> None:
    expected = {_command_page_path(root, command_id) for command_id in _all_command_ids(entries)}
    for path in _command_pages_dir(root).glob("*.md"):
        if path not in expected:
            path.unlink()


def _delete_entry_artifacts(root: Path, entry_id: str) -> None:
    if not entry_id:
        return
    for path in (_entry_json_path(root, entry_id), _entry_page_path(root, entry_id)):
        if path.exists():
            path.unlink()


def _all_command_ids(entries: Sequence[Mapping[str, Any]]) -> set[str]:
    return {str(entry.get("command_id") or "") for entry in entries if str(entry.get("command_id") or "")}


def _resolve_provider_or_none() -> Any | None:
    try:
        from g_gremlin.autopilot import llm_provider

        return llm_provider.resolve_provider()
    except Exception:
        return None


def _generate_ai_summary(entry: Mapping[str, Any], provider: Any) -> str | None:
    prompt = {
        "command_id": entry.get("command_id"),
        "status": entry.get("status"),
        "summary": entry.get("summary"),
        "issues": query.normalize_text_list(entry.get("issues"))[:8],
        "key_facts": query.normalize_text_list(entry.get("key_facts"))[:8],
        "suggested_next_steps": query.normalize_text_list(entry.get("suggested_next_steps"))[:5],
    }
    try:
        text = provider.chat(
            [
                {
                    "role": "system",
                    "content": "Summarize this CLI run in 2 sentences max. Return plain text only.",
                },
                {
                    "role": "user",
                    "content": json.dumps(prompt, ensure_ascii=True),
                },
            ],
            max_tokens=160,
        )
    except Exception:
        return None
    cleaned = re.sub(r"\s+", " ", str(text or "").strip())
    if not cleaned or cleaned.startswith('{"error"'):
        return None
    return cleaned


def _maybe_emit_gitignore_notice(root: Path, project_root: Path) -> None:
    sentinel = root / NOTICE_SENTINEL
    if sentinel.exists():
        return
    if _gitignore_covers_memory(project_root):
        return
    try:
        import click

        click.echo("Add this line to .gitignore: .g_gremlin/memory/", err=True)
    except Exception:
        pass
    sentinel.write_text("shown\n", encoding="utf-8")


def _gitignore_covers_memory(project_root: Path) -> bool:
    path = project_root / ".gitignore"
    if not path.exists():
        return False
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except Exception:
        return False
    for raw in lines:
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        normalized = line.replace("\\", "/").lstrip("/")
        if normalized.startswith(".g_gremlin/") or normalized == ".g_gremlin":
            return True
    return False


def _copy_mapping(value: Any) -> dict[str, Any]:
    if isinstance(value, Mapping):
        return {str(key): query.normalize_scalar(item) for key, item in value.items()}
    return {}


def _fallback_entry_id(created_at: str) -> str:
    compact = re.sub(r"[^0-9]", "", created_at)[:14]
    return f"memory_{compact or 'entry'}"


def _sanitize_entry_id(value: str) -> str:
    cleaned = re.sub(r"[^A-Za-z0-9._-]+", "__", str(value or "").strip())
    return cleaned or "memory_entry"


def _ensure_dirs(root: Path) -> None:
    _entries_dir(root).mkdir(parents=True, exist_ok=True)
    _entry_pages_dir(root).mkdir(parents=True, exist_ok=True)
    _command_pages_dir(root).mkdir(parents=True, exist_ok=True)


def _empty_index() -> dict[str, Any]:
    return {
        "$schema": SCHEMA_INDEX,
        "updated_at": _now_iso(),
        "retention_limit": RETENTION_LIMIT,
        "entries": [],
    }


def _entries_dir(root: Path) -> Path:
    return root / "entries"


def _entry_pages_dir(root: Path) -> Path:
    return root / "pages" / "entries"


def _command_pages_dir(root: Path) -> Path:
    return root / "pages" / "commands"


def _index_json_path(root: Path) -> Path:
    return root / "index.json"


def _index_md_path(root: Path) -> Path:
    return root / "index.md"


def _entry_json_path(root: Path, entry_id: str) -> Path:
    return _entries_dir(root) / f"{_sanitize_entry_id(entry_id)}.json"


def _entry_page_path(root: Path, entry_id: str) -> Path:
    return _entry_pages_dir(root) / f"{_sanitize_entry_id(entry_id)}.md"


def _command_page_path(root: Path, command_id: str) -> Path:
    safe_name = re.sub(r"[^A-Za-z0-9._-]+", "__", command_id)
    return _command_pages_dir(root) / f"{safe_name}.md"


def _path_text(path: Path, project_root: Path) -> str:
    candidate = path if path.is_absolute() else (project_root / path)
    try:
        relative = candidate.resolve().relative_to(project_root.resolve())
        return relative.as_posix()
    except Exception:
        return candidate.as_posix()


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()
