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

import argparse
import json
import sys
import urllib.request
from pathlib import Path
from typing import Any


DEFAULT_BASE_URL = "http://docker.cin.su:8011"
DISCOVERY_KINDS = ("Catalog", "Document", "DataProcessor", "Report")
SOURCE_TABLES = {"Config", "ConfigCAS"}
TARGET_TABLES = {"ConfigSave", "ConfigCASSave"}
SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"}


def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
    body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
    request = urllib.request.Request(
        base_url.rstrip("/") + "/rpc",
        data=body,
        method="POST",
        headers={"Content-Type": "application/json; charset=utf-8"},
    )
    with urllib.request.urlopen(request, timeout=timeout) as response:
        data = json.loads(response.read().decode("utf-8"))
    if not isinstance(data, dict):
        raise RuntimeError(f"{method} returned non-object response: {data!r}")
    return data


def sql_literal(value: str) -> str:
    return "'" + value.replace("'", "''") + "'"


def selector_from_args(args: argparse.Namespace) -> dict[str, Any]:
    selector: dict[str, Any] = {"base_id": args.base_id, "include_storage": True, "table": SOURCE_BY_TARGET[args.target_table]}
    for key in ("ref", "kind", "name", "guid"):
        value = getattr(args, key)
        if value:
            selector[key] = value
    return selector


def discover_object(base_url: str, base_id: str, source_table: str, timeout: float) -> dict[str, Any]:
    fallback: dict[str, Any] | None = None
    best: tuple[int, dict[str, Any]] | None = None
    for kind in DISCOVERY_KINDS:
        listed = rpc(
            base_url,
            "metadata.objects.list",
            {"base_id": base_id, "kind": kind, "table": source_table, "limit": 20, "include_storage": True},
            timeout,
        )
        for item in listed.get("objects") or []:
            if not isinstance(item, dict) or not item.get("guid") or not item.get("name"):
                continue
            if fallback is None:
                fallback = item
            selector = object_selector(base_id, item, default_table=source_table)
            try:
                forms = rpc(base_url, "metadata.object.forms", selector, timeout)
                modules = rpc(base_url, "metadata.object.modules", selector, timeout)
            except Exception:
                continue
            form_count = ((forms.get("counts") or {}).get("forms") or 0) if isinstance(forms.get("counts"), dict) else 0
            module_count = ((modules.get("counts") or {}).get("modules") or 0) if isinstance(modules.get("counts"), dict) else 0
            score = (10 if form_count > 0 else 0) + (10 if module_count > 0 else 0) + min(int(form_count), 5) + min(int(module_count), 5)
            if best is None or score > best[0]:
                best = (score, item)
            if form_count > 0 and module_count > 0:
                return item
    if best is not None and best[0] > 0:
        return best[1]
    if fallback is not None:
        return fallback
    raise RuntimeError(f"No {source_table} metadata object candidate found for saved-state copy planning.")


def resolve_object(base_url: str, selector: dict[str, Any], source_table: str, timeout: float) -> dict[str, Any]:
    if not any(selector.get(key) for key in ("ref", "kind", "name", "guid")):
        return discover_object(base_url, str(selector["base_id"]), source_table, timeout)
    resolved = rpc(base_url, "metadata.object.get", selector | {"table": source_table}, timeout)
    resolved_object = resolved.get("object") if isinstance(resolved.get("object"), dict) else {}
    if resolved.get("status") == "ok" and resolved_object.get("guid"):
        return resolved_object
    if selector.get("kind") or selector.get("name") or selector.get("guid"):
        listed = rpc(base_url, "metadata.objects.list", selector | {"limit": 5, "table": source_table}, timeout)
        objects = [item for item in listed.get("objects") or [] if isinstance(item, dict)]
        if objects:
            return objects[0]
    definition_query = str(selector.get("ref") or selector.get("guid") or selector.get("name") or "").strip()
    found = rpc(
        base_url,
        "metadata.definition.find",
        selector | {"query": definition_query, "areas": ["metadata"], "include_storage": True},
        timeout,
    )
    found_object = found.get("object") if isinstance(found.get("object"), dict) else {}
    if found_object.get("guid"):
        return found_object
    matches = [item for item in found.get("matches") or found.get("items") or [] if isinstance(item, dict)]
    for item in matches:
        if item.get("guid") or (item.get("object") or {}).get("guid"):
            return item.get("object") if isinstance(item.get("object"), dict) else item
    raise RuntimeError(f"Object selector did not resolve: {selector}")


def object_selector(base_id: str, obj: dict[str, Any], *, default_table: str | None = None) -> dict[str, Any]:
    selector = {"base_id": base_id, "include_storage": True}
    storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {}
    table = storage.get("table") or default_table
    if table in SOURCE_TABLES:
        selector["table"] = table
    for key in ("kind", "name", "guid"):
        if obj.get(key):
            selector[key] = obj[key]
    return selector


def add_source_row(rows: list[dict[str, Any]], *, role: str, table: str | None, file_name: str | None, extra: dict[str, Any] | None = None) -> None:
    if table not in SOURCE_TABLES or not file_name:
        return
    row = {"role": role, "table": table, "file_name": file_name}
    if extra:
        row.update(extra)
    if not any(existing.get("table") == table and existing.get("file_name") == file_name and existing.get("role") == role for existing in rows):
        rows.append(row)


def collect_source_rows(base_url: str, base_id: str, obj: dict[str, Any], timeout: float) -> tuple[list[dict[str, Any]], dict[str, Any]]:
    storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {}
    selector = object_selector(base_id, obj, default_table=storage.get("table") or "Config")
    rows: list[dict[str, Any]] = []
    evidence: dict[str, Any] = {"forms": None, "modules": None}

    add_source_row(rows, role="object", table=storage.get("table") or "Config", file_name=obj.get("guid"))

    forms = rpc(base_url, "metadata.object.forms", selector, timeout)
    evidence["forms"] = {"status": forms.get("status"), "counts": forms.get("counts")}
    for form in forms.get("forms") or []:
        if not isinstance(form, dict):
            continue
        source = form.get("source") if isinstance(form.get("source"), dict) else {}
        add_source_row(
            rows,
            role="form",
            table=source.get("table"),
            file_name=source.get("file_name"),
            extra={"name": form.get("name"), "guid": form.get("guid")},
        )
        add_source_row(
            rows,
            role="form_payload",
            table=source.get("table"),
            file_name=f"{source.get('file_name')}.0" if source.get("file_name") else None,
            extra={"name": form.get("name"), "guid": form.get("guid"), "suffix": ".0"},
        )

    modules = rpc(base_url, "metadata.object.modules", selector, timeout)
    evidence["modules"] = {"status": modules.get("status"), "counts": modules.get("counts")}
    for module in modules.get("modules") or []:
        if not isinstance(module, dict):
            continue
        add_source_row(
            rows,
            role="module",
            table=module.get("table"),
            file_name=module.get("file_name"),
            extra={
                "module_id": module.get("module_id"),
                "stream_index": module.get("stream_index"),
                "sha1": module.get("sha1"),
                "bytes": module.get("bytes"),
            },
        )
    return rows, evidence


def read_row_details(base_url: str, base_id: str, rows: list[dict[str, Any]], timeout: float) -> dict[str, Any]:
    details: dict[str, Any] = {}
    by_table: dict[str, list[str]] = {}
    for row in rows:
        table = str(row.get("table") or "")
        file_name = str(row.get("file_name") or "")
        if table in SOURCE_TABLES and file_name:
            by_table.setdefault(table, []).append(file_name)
    for table, names in sorted(by_table.items()):
        unique_names = sorted(set(names))
        query = (
            "SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, "
            "CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 "
            f"FROM {table} WHERE FileName IN ({', '.join(sql_literal(name) for name in unique_names)})"
        )
        result = rpc(
            base_url,
            "query.run",
            {"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)},
            timeout,
        )
        details[table] = {
            "status": result.get("status"),
            "rows": result.get("rows") or [],
            "counts": result.get("counts"),
        }
    return details


def read_target_collisions(base_url: str, base_id: str, rows: list[dict[str, Any]], target_table: str, timeout: float) -> dict[str, Any]:
    file_names = sorted({str(row.get("file_name") or "") for row in rows if row.get("file_name")})
    if not file_names:
        return {"status": "skipped_no_source_rows", "rows": [], "counts": {"rows": 0}}
    query = (
        "SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, "
        "CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 "
        f"FROM {target_table} WHERE FileName IN ({', '.join(sql_literal(name) for name in file_names)})"
    )
    result = rpc(
        base_url,
        "query.run",
        {"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)},
        timeout,
    )
    collisions = result.get("rows") or []
    return {
        "status": "collision" if collisions else "clear",
        "table": target_table,
        "rows": collisions,
        "counts": result.get("counts"),
    }


def build_report(args: argparse.Namespace) -> dict[str, Any]:
    expected_source_table = SOURCE_BY_TARGET[args.target_table]
    selector = selector_from_args(args)
    obj = resolve_object(args.base_url, selector, expected_source_table, args.timeout)
    rows, evidence = collect_source_rows(args.base_url, args.base_id, obj, args.timeout)
    row_details = read_row_details(args.base_url, args.base_id, rows, args.timeout)
    target_collisions = read_target_collisions(args.base_url, args.base_id, rows, args.target_table, args.timeout)
    found_rows = sum(len((table_details.get("rows") or [])) for table_details in row_details.values() if isinstance(table_details, dict))
    active_source_tables = sorted({str(row.get("table")) for row in rows if row.get("table") in SOURCE_TABLES})
    mismatched_source_tables = sorted(table for table in active_source_tables if table != expected_source_table)
    collision_rows = target_collisions.get("rows") if isinstance(target_collisions.get("rows"), list) else []

    if mismatched_source_tables:
        status = "blocked_source_target_family_mismatch"
    elif collision_rows:
        status = "blocked_target_collision"
    elif rows and found_rows:
        status = "plan_ready"
    else:
        status = "blocked_no_active_source_rows"
    recommendations = [
        "Do not edit Config/ConfigCAS directly for the smoke. Prepare the save layer first, then run strict readiness.",
        f"Copy the target object's required active rows from {expected_source_table} into {args.target_table} using the approved saved-state workflow.",
        "After the copy, rerun check_1c_saved_state_strict_readiness.py and only then enable strict saved-state smoke.",
    ]
    if mismatched_source_tables:
        recommendations.insert(1, f"{args.target_table} must be prepared only from {expected_source_table}; found source table(s): {', '.join(mismatched_source_tables)}.")
    if collision_rows:
        recommendations.insert(1, f"{args.target_table} already contains one or more planned FileName values; inspect/clear the existing unactivated change before copying.")
    return {
        "schema": "onec_saved_state_copy_plan.v1",
        "base_url": args.base_url,
        "base_id": args.base_id,
        "status": status,
        "ready_to_copy": status == "plan_ready",
        "selector": selector,
        "object": {
            "guid": obj.get("guid"),
            "kind": obj.get("kind"),
            "name": obj.get("name"),
            "synonym": obj.get("synonym"),
            "source": obj.get("source"),
        },
        "target": {
            "table": args.target_table,
            "mode": "prepare_saved_state_working_copy",
        },
        "source_family": {
            "target_table": args.target_table,
            "expected_source_table": expected_source_table,
            "source_tables": active_source_tables,
            "mismatched_source_tables": mismatched_source_tables,
            "valid": not mismatched_source_tables,
        },
        "source_rows": rows,
        "source_row_details": row_details,
        "target_collisions": target_collisions,
        "evidence": evidence,
        "summary": {
            "planned_source_rows": len(rows),
            "found_source_storage_rows": found_rows,
            "target_collision_rows": len(collision_rows),
            "active_source_tables": active_source_tables,
        },
        "recommendations": recommendations,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Read-only plan for preparing a 1C saved-state copy from an active object.")
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
    parser.add_argument("--base-id", default="upo_test")
    parser.add_argument("--ref")
    parser.add_argument("--kind")
    parser.add_argument("--name")
    parser.add_argument("--guid")
    parser.add_argument("--target-table", choices=sorted(TARGET_TABLES), default="ConfigSave")
    parser.add_argument("--timeout", type=float, default=60.0)
    parser.add_argument("--report", type=Path)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    try:
        report = build_report(args)
    except Exception as exc:
        report = {
            "schema": "onec_saved_state_copy_plan.v1",
            "base_url": args.base_url,
            "base_id": args.base_id,
            "status": "error",
            "ready_to_copy": False,
            "error": str(exc),
        }
        if args.report:
            args.report.parent.mkdir(parents=True, exist_ok=True)
            args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr)
        return 1

    if args.report:
        args.report.parent.mkdir(parents=True, exist_ok=True)
        args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    if args.json or report["status"] != "plan_ready":
        print(json.dumps(report, ensure_ascii=False, indent=2))
    else:
        print(
            "OK: saved-state copy plan ready for "
            f"{report['object'].get('kind')}.{report['object'].get('name')} -> {report['target'].get('table')}."
        )
    return 0 if report["status"] == "plan_ready" else 1


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