#!/usr/bin/env python3
"""Analyze saved-state object changes beyond storage bytes."""

from __future__ import annotations

import argparse
import difflib
import importlib.util
import json
import re
import sys
from pathlib import Path
from typing import Any


WORD_RE = re.compile(r"[\wА-Яа-яЁё]{3,}", re.UNICODE)
BASE64ISH_RE = re.compile(r"^[A-Za-z0-9+/=_-]{24,}$")
REPO_ROOT = Path(__file__).resolve().parents[1]


def load_payload_to_text():
    module_path = REPO_ROOT / "plugins" / "1c" / "parser" / "payload.py"
    spec = importlib.util.spec_from_file_location("onec_payload", module_path)
    if spec is None or spec.loader is None:
        raise RuntimeError(f"Cannot load payload parser: {module_path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module.payload_to_text


payload_to_text = load_payload_to_text()


def load_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8-sig"))


def write_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def safe_join(root: Path, file_name: str) -> Path:
    relative = Path(file_name.replace("\\", "/"))
    if relative.is_absolute() or ".." in relative.parts or not str(relative):
        raise ValueError(file_name)
    return root / relative


def words(text: str) -> list[str]:
    result: list[str] = []
    seen: set[str] = set()
    for match in WORD_RE.finditer(text):
        value = match.group(0)
        key = value.casefold()
        if key not in seen:
            result.append(value)
            seen.add(key)
    return result


def semantic_words(values: list[str], *, limit: int = 40) -> list[str]:
    result: list[str] = []
    seen: set[str] = set()
    for value in values:
        if not value:
            continue
        if BASE64ISH_RE.match(value):
            continue
        if value.isdigit():
            continue
        if len(value) > 80:
            continue
        has_cyrillic = any("А" <= char <= "я" or char in "Ёё" for char in value)
        has_1c_shape = any(marker in value for marker in ("Форма", "Команда", "Реквизит", "Модуль", "Область", "Процедура", "Функция"))
        if not has_cyrillic and not has_1c_shape:
            continue
        key = value.casefold()
        if key in seen:
            continue
        result.append(value)
        seen.add(key)
        if len(result) >= limit:
            break
    return result


def classify_payload_part(item: dict[str, Any], file_name: str) -> str:
    kind = str(item.get("kind") or "")
    suffix = ""
    if "." in file_name:
        suffix = file_name.rsplit(".", 1)[1]
    if kind in {"CommonModule", "ObjectModule", "ManagerModule"} and suffix == "0":
        return "bsl_module_text"
    if kind == "Form" and not suffix:
        return "form_descriptor"
    if kind == "Form" and suffix == "0":
        return "form_body"
    if suffix == "0":
        return "primary_payload"
    return "metadata_payload"


def textish_from_payload(decoded: dict[str, Any]) -> tuple[str | None, str | None]:
    text = decoded.get("text")
    if text:
        return text, str(decoded.get("encoding") or "")
    payload = decoded.get("payload")
    if not isinstance(payload, bytes):
        return None, None
    candidates: list[tuple[str, str, int]] = []
    for encoding in ("utf-8-sig", "utf-8", "cp1251", "utf-16-le"):
        try:
            candidate = payload.decode(encoding, errors="ignore").replace("\x00", "").replace("\ufeff", "")
        except Exception:
            continue
        word_count = len(words(candidate))
        cyrillic_count = sum(1 for char in candidate if "А" <= char <= "я" or char in "Ёё")
        known_1c_terms = sum(
            candidate.count(term)
            for term in ("Процедура", "Функция", "Конец", "Если", "Тогда", "Область", "Перем", "Экспорт", "пример")
        )
        mojibake_penalty = candidate.count("Р") * 8 + candidate.count("С") * 4
        score = word_count * 5 + cyrillic_count + known_1c_terms * 500 - mojibake_penalty
        if word_count:
            candidates.append((candidate, f"{encoding}:lossy", score))
    if not candidates:
        return None, None
    candidates.sort(key=lambda item: item[2], reverse=True)
    return candidates[0][0], candidates[0][1]


def common_edges(left: str, right: str) -> tuple[int, int]:
    prefix = 0
    for a, b in zip(left, right):
        if a != b:
            break
        prefix += 1
    suffix = 0
    left_tail = left[prefix:]
    right_tail = right[prefix:]
    for a, b in zip(reversed(left_tail), reversed(right_tail)):
        if a != b:
            break
        suffix += 1
    return prefix, suffix


def text_window(text: str, center: int, size: int = 500) -> str:
    start = max(center - size // 2, 0)
    end = min(center + size // 2, len(text))
    return text[start:end].replace("\x00", "")


def line_diff(left: str, right: str, *, limit: int) -> list[str]:
    left_lines = left.splitlines()
    right_lines = right.splitlines()
    diff = list(difflib.unified_diff(left_lines, right_lines, fromfile="active", tofile="saved", lineterm=""))
    if len(diff) > limit:
        return [*diff[:limit], f"... truncated {len(diff) - limit} lines ..."]
    return diff


def analyze_payload(active_path: Path | None, saved_path: Path) -> dict[str, Any]:
    saved_raw = saved_path.read_bytes()
    saved_decoded = payload_to_text(saved_raw)
    active_decoded: dict[str, Any] | None = None
    if active_path and active_path.exists():
        active_decoded = payload_to_text(active_path.read_bytes())

    result: dict[str, Any] = {
        "saved_path": str(saved_path),
        "active_path": str(active_path) if active_path else None,
        "saved": {
            "raw_bytes": saved_decoded.get("raw_bytes"),
            "payload_bytes": saved_decoded.get("payload_bytes"),
            "compression": saved_decoded.get("compression"),
            "encoding": saved_decoded.get("encoding"),
        },
        "active": None,
        "text_comparable": False,
    }
    if active_decoded:
        result["active"] = {
            "raw_bytes": active_decoded.get("raw_bytes"),
            "payload_bytes": active_decoded.get("payload_bytes"),
            "compression": active_decoded.get("compression"),
            "encoding": active_decoded.get("encoding"),
        }

    saved_text, saved_text_mode = textish_from_payload(saved_decoded)
    active_text, active_text_mode = textish_from_payload(active_decoded) if active_decoded else (None, None)
    result["saved"]["text_mode"] = saved_text_mode
    if result["active"] is not None:
        result["active"]["text_mode"] = active_text_mode
    if saved_text is None:
        result["summary"] = "Saved payload is not text-decodable."
        return result
    result["saved_strings_sample"] = words(saved_text)[:80]
    if active_text is None:
        result["summary"] = "Saved text payload has no active counterpart."
        result["text_comparable"] = False
        result["saved_text_sample"] = text_window(saved_text, 0)
        return result

    result["text_comparable"] = True
    prefix, suffix = common_edges(active_text, saved_text)
    active_words = {value.casefold(): value for value in words(active_text)}
    saved_words = {value.casefold(): value for value in words(saved_text)}
    added_keys = [key for key in saved_words if key not in active_words]
    removed_keys = [key for key in active_words if key not in saved_words]
    result["text_diff"] = {
        "active_chars": len(active_text),
        "saved_chars": len(saved_text),
        "delta_chars": len(saved_text) - len(active_text),
        "common_prefix_chars": prefix,
        "common_suffix_chars": suffix,
        "added_words": [saved_words[key] for key in added_keys[:80]],
        "removed_words": [active_words[key] for key in removed_keys[:80]],
        "active_window": text_window(active_text, prefix),
        "saved_window": text_window(saved_text, prefix),
        "unified_diff": line_diff(active_text, saved_text, limit=120),
    }
    result["semantic_hints"] = {
        "added_terms": semantic_words(result["text_diff"]["added_words"]),
        "removed_terms": semantic_words(result["text_diff"]["removed_words"]),
    }
    result["summary"] = "Text payload differs." if active_text != saved_text else "Text payload matches."
    return result


def storage_root(saved_table: str, active_table: str, roots: dict[str, Path]) -> tuple[Path | None, Path | None]:
    saved_root = roots.get(saved_table)
    active_root = roots.get(active_table)
    return saved_root, active_root


def build_extension_cas_map(summary_path: Path | None) -> dict[tuple[str, str], str]:
    if not summary_path or not summary_path.exists():
        return {}
    data = load_json(summary_path)
    result: dict[tuple[str, str], str] = {}
    for extension in data.get("extensions") or []:
        extension_name = str(extension.get("extension_name") or "")
        for obj in extension.get("sample_objects") or []:
            for part in obj.get("parts") or []:
                object_id = str(part.get("object_id") or "").casefold()
                cas_key = str(part.get("cas_key") or "")
                if extension_name and object_id and cas_key:
                    result[(extension_name.casefold(), object_id)] = cas_key
    return result


def extension_object_id_from_saved_file(file_name: str) -> str | None:
    if "__" not in file_name:
        return None
    object_id = file_name.split("__", 1)[1]
    if object_id == "configinfo":
        return None
    return object_id.casefold()


def active_extension_path(item: dict[str, Any], file_name: str, extension_cas_map: dict[tuple[str, str], str], config_cas_all_dir: Path | None) -> tuple[Path | None, str | None]:
    if not config_cas_all_dir:
        return None, None
    extension = str(item.get("extension") or "").casefold()
    object_id = extension_object_id_from_saved_file(file_name)
    if not extension or not object_id:
        return None, None
    cas_key = extension_cas_map.get((extension, object_id))
    if not cas_key:
        return None, None
    path = config_cas_all_dir / cas_key
    return (path if path.exists() else None), cas_key


def analyze(comparison: dict[str, Any], roots: dict[str, Path], *, extension_manifest_summary: Path | None = None, config_cas_all_dir: Path | None = None) -> dict[str, Any]:
    extension_cas_map = build_extension_cas_map(extension_manifest_summary)
    objects = []
    for item in comparison.get("object_changes") or []:
        details = []
        for storage in item.get("storage") or []:
            saved_root, active_root = storage_root(str(storage.get("saved_table")), str(storage.get("active_table")), roots)
            file_name = str(storage.get("file_name") or "")
            if not saved_root:
                details.append({"file_name": file_name, "error": f"Missing saved root for {storage.get('saved_table')}"})
                continue
            try:
                saved_path = safe_join(saved_root, file_name)
                active_path = safe_join(active_root, file_name) if active_root else None
            except ValueError:
                details.append({"file_name": file_name, "error": "Unsafe storage file name."})
                continue
            if not saved_path.exists():
                details.append({"file_name": file_name, "error": f"Saved payload file is missing: {saved_path}"})
                continue
            active_cas_key = None
            if not (active_path and active_path.exists()) and storage.get("saved_table") == "ConfigCASSave":
                active_path, active_cas_key = active_extension_path(item, file_name, extension_cas_map, config_cas_all_dir)
            payload_detail = analyze_payload(active_path if active_path and active_path.exists() else None, saved_path)
            details.append({
                "file_name": file_name,
                "payload_role": classify_payload_part(item, file_name),
                "saved_table": storage.get("saved_table"),
                "active_table": storage.get("active_table"),
                "active_exists": storage.get("active_exists"),
                "active_cas_key": active_cas_key,
                "payload": payload_detail,
            })
        objects.append({
            "full_name": item.get("full_name"),
            "layer": item.get("layer"),
            "extension": item.get("extension"),
            "kind": item.get("kind"),
            "kind_ru": item.get("kind_ru"),
            "name": item.get("name"),
            "synonym": item.get("synonym"),
            "change_state": item.get("change_state"),
            "details": details,
        })
    return {
        "schema": "onec_saved_state_object_detail.v1",
        "source_schema": comparison.get("schema"),
        "database": comparison.get("database"),
        "view": comparison.get("view"),
        "object_details": objects,
        "counts": {
            "objects": len(objects),
            "details": sum(len(item.get("details") or []) for item in objects),
        },
        "safety": {
            "read_only": True,
            "sql_write_performed": False,
            "public_terms_are_1c_objects": True,
        },
        "active_extension_resolution": {
            "extension_manifest_summary": str(extension_manifest_summary) if extension_manifest_summary else None,
            "config_cas_all_dir": str(config_cas_all_dir) if config_cas_all_dir else None,
            "mapped_parts": len(extension_cas_map),
        },
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Analyze saved-state object changes beyond storage bytes.")
    parser.add_argument("--comparison", type=Path, required=True)
    parser.add_argument("--config-save-dir", type=Path, required=True)
    parser.add_argument("--config-dir", type=Path, required=True)
    parser.add_argument("--config-cas-save-dir", type=Path, required=True)
    parser.add_argument("--config-cas-dir", type=Path, required=True)
    parser.add_argument("--extension-manifest-summary", type=Path)
    parser.add_argument("--config-cas-all-dir", type=Path)
    parser.add_argument("--output", type=Path)
    args = parser.parse_args()

    roots = {
        "ConfigSave": args.config_save_dir,
        "Config": args.config_dir,
        "ConfigCASSave": args.config_cas_save_dir,
        "ConfigCAS": args.config_cas_dir,
    }
    result = analyze(
        load_json(args.comparison),
        roots,
        extension_manifest_summary=args.extension_manifest_summary,
        config_cas_all_dir=args.config_cas_all_dir,
    )
    if args.output:
        write_json(args.output, result)
    print(json.dumps({"output": str(args.output) if args.output else None, "schema": result["schema"], "counts": result["counts"]}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
