import json
import sys
from pathlib import Path


MODEL_REQUIRED_FIELDS = [
    "model_id",
    "model_name",
    "model_type",
    "pipeline_position",
    "one_sentence_definition",
    "core_question",
    "core_mechanism",
    "status",
    "source_articles",
    "source_evidence",
    "input_types",
    "output_types",
    "call_when",
    "do_not_call_when",
    "trigger_keywords",
    "negative_triggers",
    "related_models",
    "conflicting_models",
    "disciplinary_anchors",
    "common_misuses",
    "failure_modes",
    "selection_priority",
    "confidence_level",
    "stability_profile",
    "regression_status",
    "example_inputs",
    "example_outputs",
    "output_contract",
    "productization_notes",
    "version",
    "last_updated",
]

MODEL_TYPE_VALUES = {
    "routing_model",
    "deep_modeling_model",
    "lens_model",
    "diagnostic_model",
    "evaluation_model",
    "generation_model",
    "conflict_resolution_model",
    "stabilization_model",
}

PIPELINE_POSITION_VALUES = {
    "pre_analysis",
    "analysis",
    "deep_analysis",
    "synthesis",
    "red_team",
    "evaluation",
    "post_processing",
}

CONFIDENCE_LEVEL_VALUES = {"high", "medium", "low"}
REGRESSION_STATUS_VALUES = {"not_started", "pending", "in_progress", "passed", "failed", "needs_rebuild"}
STABILITY_LEVEL_VALUES = {"A", "B", "C", "D"}
REGRESSION_CASE_TYPE_VALUES = {"positive", "boundary", "misuse", "no_call", "selector_gate", "pipeline"}
REQUIRED_REGRESSION_CASE_TYPES = {"positive", "boundary", "misuse", "no_call", "selector_gate", "pipeline"}
MIN_REGRESSION_CASES_PER_MODEL = 15
EXPECTED_CLASSIFICATION_VALUES = {"question", "problem", "issue", "mixed", "no_call", "not_applicable"}
EXPECTED_DOMINANT_SCARCITY_VALUES = {"data", "path_resource", "consensus_order", "mixed", "unknown", "not_applicable"}
EXPECTED_MAX_DEPTH_VALUES = {"application", "domain", "process", "purpose", "core_mechanism", "human_capability", "philosophical_bedrock", "no_call", "not_applicable"}
EVALUATION_MODE_VALUES = {"manual", "keyword", "structured", "semantic"}
STATUS_VALUES = {"draft", "review", "reviewed", "callable", "stable", "archived", "deprecated", "draft_pre_contract"}
QPI_COMPLEXITY_PATTERN_VALUES = {"not_mixed", "intra_frame_mixed", "inter_viewpoint_divergence"}

MODEL_SPECIFIC_STRUCTURED_OUTPUT_FIELDS = {
    "qpi": [
        "classification_scope",
        "is_provisional",
        "subject_position",
        "scenario_context",
        "responsibility_scope",
        "context_sufficiency",
        "missing_context",
        "problem_owner",
        "problem_source",
        "time_scale",
        "scarcity_profile",
        "dominant_scarcity",
        "classification",
        "classification_confidence",
        "evidence_gap",
        "misclassification_risk",
        "recommended_next_step",
        "next_model_candidates",
    ],
    "intellectual_archaeology": [
        "should_call",
        "entry_reason",
        "recommended_max_depth",
        "layers_to_analyze",
        "stop_reason",
        "no_deeper_reason",
        "assumptions_by_layer",
        "validation_needed",
        "action_implication",
    ],
}

STABILITY_REQUIRED_FIELDS = [
    "stability_level",
    "needs_stabilization",
    "main_risks",
    "reason",
    "next_stabilization_action",
]

SOURCE_ARTICLE_REQUIRED_FIELDS = [
    "source_id",
    "title",
    "source_type",
    "related_models",
    "source_status",
]

SOURCE_EXCERPT_REQUIRED_FIELDS = [
    "excerpt_id",
    "source_id",
    "related_model_id",
    "excerpt_type",
    "summary",
    "used_for",
    "quote_status",
    "source_location",
]

REGRESSION_CASE_REQUIRED_FIELDS = [
    "case_id",
    "model_id",
    "case_type",
    "input",
    "expected_behavior",
    "failure_signal",
]

SOURCE_TYPE_VALUES = {"original_article", "synthesis_note", "placeholder"}
SOURCE_STATUS_VALUES = {"representative", "derived_synthesis", "placeholder"}
EXCERPT_TYPE_VALUES = {"definition", "taxonomy", "mechanism", "application_rule", "value_claim", "boundary_rule", "validation_rule"}
QUOTE_STATUS_VALUES = {"exact", "condensed", "paraphrased"}


def read_json(path):
    if not path.exists():
        return None, [f"missing file {path.as_posix()}"]
    try:
        return json.loads(path.read_text(encoding="utf-8")), []
    except json.JSONDecodeError as exc:
        return None, [f"{path.as_posix()} is invalid JSON: {exc}"]


def require_fields(record, fields, label):
    return [f"{label} missing required field {field}" for field in fields if field not in record]


def collect_duplicate_errors(records, key, label):
    seen = set()
    errors = []
    for record in records:
        value = record.get(key)
        if value in seen:
            errors.append(f"duplicate {label} {value}")
        seen.add(value)
    return errors


def load_collection(root, relative_path, collection_key):
    data, errors = read_json(root / relative_path)
    if errors:
        return [], errors
    if not isinstance(data, dict) or collection_key not in data or not isinstance(data[collection_key], list):
        return [], [f"{relative_path} must contain list field {collection_key}"]
    return data[collection_key], []


def validate_enum(record, field, allowed_values, label):
    value = record.get(field)
    if value is not None and value not in allowed_values:
        return [f"{label} field {field} has invalid value {value}"]
    return []


def validate_integer_range(record, field, minimum, maximum, label):
    value = record.get(field)
    if value is None:
        return []
    errors = []
    if not isinstance(value, int):
        return [f"{label} field {field} must be an integer"]
    if value < minimum:
        errors.append(f"{label} field {field} must be >= {minimum}")
    if value > maximum:
        errors.append(f"{label} field {field} must be <= {maximum}")
    return errors


def validate_model_contract(model, label):
    errors = []
    errors.extend(require_fields(model, MODEL_REQUIRED_FIELDS, label))
    errors.extend(validate_enum(model, "model_type", MODEL_TYPE_VALUES, label))
    errors.extend(validate_enum(model, "pipeline_position", PIPELINE_POSITION_VALUES, label))
    errors.extend(validate_enum(model, "confidence_level", CONFIDENCE_LEVEL_VALUES, label))
    errors.extend(validate_enum(model, "regression_status", REGRESSION_STATUS_VALUES, label))
    errors.extend(validate_enum(model, "status", STATUS_VALUES, label))
    errors.extend(validate_integer_range(model, "selection_priority", 1, 10, label))

    stability_profile = model.get("stability_profile")
    if isinstance(stability_profile, dict):
        errors.extend(require_fields(stability_profile, STABILITY_REQUIRED_FIELDS, f"{label} stability_profile"))
        errors.extend(validate_enum(stability_profile, "stability_level", STABILITY_LEVEL_VALUES, f"{label} stability_profile"))
        needs_stabilization = stability_profile.get("needs_stabilization")
        if needs_stabilization is not None and not isinstance(needs_stabilization, bool):
            errors.append(f"{label} stability_profile field needs_stabilization must be a boolean")
    elif stability_profile is not None:
        errors.append(f"{label} field stability_profile must be an object")

    errors.extend(validate_structured_output_contract(model, label))

    return errors


def validate_structured_output_contract(model, label):
    model_id = model.get("model_id")
    required_fields = MODEL_SPECIFIC_STRUCTURED_OUTPUT_FIELDS.get(model_id, [])
    if not required_fields:
        return []

    contract = model.get("structured_output_contract")
    if not isinstance(contract, dict):
        return [f"{label} model {model_id} missing structured_output_contract object"]

    errors = []
    for field in required_fields:
        if field not in contract:
            errors.append(f"{label} model {model_id} structured_output_contract missing required output field {field}")
    return errors


def validate_qpi_case_digests(root):
    path = root / "selector" / "qpi_case_digests.json"
    if not path.exists():
        return []

    data, errors = read_json(path)
    if errors:
        return errors
    if not isinstance(data, dict) or not isinstance(data.get("cases"), list):
        return ["selector/qpi_case_digests.json must contain list field cases"]

    errors = []
    for case in data["cases"]:
        case_id = case.get("case_id", "<missing>")
        label = f"qpi case digest {case_id}"
        if "misframing_risks" in case:
            errors.append(f"{label} uses deprecated field misframing_risks; use misclassification_risk")
        if "mixed_or_multi_perspective" in case:
            errors.append(f"{label} uses deprecated field mixed_or_multi_perspective; use qpi_complexity_pattern")
        if "misclassification_risk" not in case:
            errors.append(f"{label} missing required field misclassification_risk")
        pattern = case.get("qpi_complexity_pattern")
        if pattern is None:
            errors.append(f"{label} missing required field qpi_complexity_pattern")
        elif pattern not in QPI_COMPLEXITY_PATTERN_VALUES:
            errors.append(f"{label} field qpi_complexity_pattern has invalid value {pattern}")
        if case.get("classification_scope") == "multi_perspective" or pattern == "inter_viewpoint_divergence":
            has_viewpoint_detail = bool(case.get("classification_by_viewpoint")) or bool(case.get("viewpoint_summary"))
            if not has_viewpoint_detail:
                errors.append(f"{label} multi_perspective case requires classification_by_viewpoint or viewpoint_summary")
    return errors


def load_model_index(root):
    path = root / "models" / "model_index.json"
    data, errors = read_json(path)
    if errors:
        return None, errors
    if not isinstance(data, dict) or "models" not in data or not isinstance(data["models"], list):
        return None, ["models/model_index.json must contain list field models"]
    return data, []


def card_index_text(root):
    path = root / "cards" / "card_index.md"
    if not path.exists():
        return None, ["missing file cards/card_index.md"]
    return path.read_text(encoding="utf-8"), []


def parse_card_index_model_ids(text):
    model_ids = set()
    for line in text.splitlines():
        stripped = line.strip()
        if not stripped.startswith("|") or stripped.startswith("| ---") or "Model ID" in stripped:
            continue
        cells = [cell.strip() for cell in stripped.strip("|").split("|")]
        if cells and cells[0]:
            model_ids.add(cells[0])
    return model_ids


def validate_library(root):
    root = Path(root)
    errors = []

    source_articles, article_errors = load_collection(root, "sources/source_articles.json", "source_articles")
    source_excerpts, excerpt_errors = load_collection(root, "sources/source_excerpts.json", "source_excerpts")
    regression_cases, case_errors = load_collection(root, "tests/regression_cases.json", "regression_cases")
    errors.extend(article_errors)
    errors.extend(excerpt_errors)
    errors.extend(case_errors)
    errors.extend(validate_qpi_case_digests(root))

    for article in source_articles:
        errors.extend(require_fields(article, SOURCE_ARTICLE_REQUIRED_FIELDS, f"source article {article.get('source_id', '<missing>')}"))
        errors.extend(validate_enum(article, "source_type", SOURCE_TYPE_VALUES, f"source article {article.get('source_id', '<missing>')}"))
        errors.extend(validate_enum(article, "source_status", SOURCE_STATUS_VALUES, f"source article {article.get('source_id', '<missing>')}"))
    for excerpt in source_excerpts:
        errors.extend(require_fields(excerpt, SOURCE_EXCERPT_REQUIRED_FIELDS, f"source excerpt {excerpt.get('excerpt_id', '<missing>')}"))
        errors.extend(validate_enum(excerpt, "excerpt_type", EXCERPT_TYPE_VALUES, f"source excerpt {excerpt.get('excerpt_id', '<missing>')}"))
        errors.extend(validate_enum(excerpt, "quote_status", QUOTE_STATUS_VALUES, f"source excerpt {excerpt.get('excerpt_id', '<missing>')}"))
        if excerpt.get("quote_status") == "exact":
            raw_excerpt = excerpt.get("raw_excerpt", "")
            if "..." in raw_excerpt or "……" in raw_excerpt:
                errors.append(f"source excerpt {excerpt.get('excerpt_id')} quote_status exact must not contain ellipsis")
        if excerpt.get("quote_status") == "condensed" and not excerpt.get("notes"):
            errors.append(f"source excerpt {excerpt.get('excerpt_id')} quote_status condensed requires notes")
    for case in regression_cases:
        errors.extend(require_fields(case, REGRESSION_CASE_REQUIRED_FIELDS, f"regression case {case.get('case_id', '<missing>')}"))
        errors.extend(validate_enum(case, "case_type", REGRESSION_CASE_TYPE_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
        errors.extend(validate_enum(case, "expected_classification", EXPECTED_CLASSIFICATION_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
        errors.extend(validate_enum(case, "expected_dominant_scarcity", EXPECTED_DOMINANT_SCARCITY_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
        errors.extend(validate_enum(case, "expected_max_depth", EXPECTED_MAX_DEPTH_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
        errors.extend(validate_enum(case, "evaluation_mode", EVALUATION_MODE_VALUES, f"regression case {case.get('case_id', '<missing>')}"))
        if "should_call_model" in case and not isinstance(case.get("should_call_model"), bool):
            errors.append(f"regression case {case.get('case_id')} field should_call_model must be a boolean")

    errors.extend(collect_duplicate_errors(source_articles, "source_id", "source article id"))
    errors.extend(collect_duplicate_errors(source_excerpts, "excerpt_id", "source excerpt id"))
    errors.extend(collect_duplicate_errors(regression_cases, "case_id", "regression case id"))

    article_ids = {article.get("source_id") for article in source_articles}
    excerpt_ids = {excerpt.get("excerpt_id") for excerpt in source_excerpts}

    model_records = []
    model_paths = sorted((root / "models").glob("*.model.json")) if (root / "models").exists() else []
    if not model_paths:
        errors.append("models/ contains no *.model.json files")

    for model_path in model_paths:
        relative_model_path = model_path.relative_to(root).as_posix()
        model, model_errors = read_json(model_path)
        errors.extend(model_errors)
        if model is None:
            continue
        model_records.append((relative_model_path, model))
        errors.extend(validate_model_contract(model, relative_model_path))

    model_ids = {model.get("model_id") for _, model in model_records}
    models_by_id = {model.get("model_id"): (relative_path, model) for relative_path, model in model_records}
    errors.extend(collect_duplicate_errors([model for _, model in model_records], "model_id", "model id"))

    for relative_model_path, model in model_records:
        for source_id in model.get("source_articles", []):
            if source_id not in article_ids:
                errors.append(f"{relative_model_path} references unknown source article {source_id}")
        for excerpt_id in model.get("source_evidence", []):
            if excerpt_id not in excerpt_ids:
                errors.append(f"{relative_model_path} references unknown source excerpt {excerpt_id}")

    for excerpt in source_excerpts:
        source_id = excerpt.get("source_id")
        related_model_id = excerpt.get("related_model_id")
        if source_id and source_id not in article_ids:
            errors.append(f"source excerpt {excerpt.get('excerpt_id')} references unknown source article {source_id}")
        if related_model_id and related_model_id not in model_ids:
            errors.append(f"source excerpt {excerpt.get('excerpt_id')} references unknown model {related_model_id}")

    for case in regression_cases:
        model_id = case.get("model_id")
        if model_id and model_id not in model_ids:
            errors.append(f"regression case {case.get('case_id')} references unknown model {model_id}")

    model_index, model_index_errors = load_model_index(root)
    errors.extend(model_index_errors)
    indexed_model_ids = set()
    if model_index:
        for entry in model_index["models"]:
            entry_id = entry.get("model_id")
            indexed_model_ids.add(entry_id)
            model_file = entry.get("model_file")
            card_file = entry.get("card_file")
            if model_file and not (root / model_file).exists():
                errors.append(f"models/model_index.json references missing model file {model_file}")
            if card_file and not (root / card_file).exists():
                errors.append(f"models/model_index.json references missing card file {card_file}")
            if entry_id in models_by_id:
                relative_model_path, model = models_by_id[entry_id]
                expected_values = {
                    "model_name": model.get("model_name"),
                    "model_type": model.get("model_type"),
                    "pipeline_position": model.get("pipeline_position"),
                    "model_file": relative_model_path,
                    "source_article_count": len(model.get("source_articles", [])),
                    "source_evidence_count": len(model.get("source_evidence", [])),
                    "stability_level": model.get("stability_profile", {}).get("stability_level") if isinstance(model.get("stability_profile"), dict) else None,
                    "regression_status": model.get("regression_status"),
                    "status": model.get("status"),
                }
                for field, expected_value in expected_values.items():
                    actual_value = entry.get(field)
                    if actual_value != expected_value:
                        errors.append(f"models/model_index.json entry {entry_id} {field} is {actual_value}, expected {expected_value}")

    card_index, card_index_errors = card_index_text(root)
    errors.extend(card_index_errors)
    card_index_model_ids = parse_card_index_model_ids(card_index) if card_index is not None else set()

    cases_by_model = {}
    case_types_by_model = {}
    for case in regression_cases:
        model_id = case.get("model_id")
        if not model_id:
            continue
        cases_by_model[model_id] = cases_by_model.get(model_id, 0) + 1
        case_types_by_model.setdefault(model_id, set()).add(case.get("case_type"))

    if model_index:
        for entry in model_index["models"]:
            entry_id = entry.get("model_id")
            expected_case_count = cases_by_model.get(entry_id, 0)
            actual_case_count = entry.get("regression_case_count")
            if actual_case_count != expected_case_count:
                errors.append(f"models/model_index.json entry {entry_id} regression_case_count is {actual_case_count}, expected {expected_case_count}")
            if card_index is not None and entry_id not in card_index_model_ids:
                errors.append(f"models/model_index.json entry {entry_id} missing from cards/card_index.md")

    if model_index and card_index is not None:
        for card_model_id in sorted(card_index_model_ids):
            if card_model_id not in indexed_model_ids:
                errors.append(f"cards/card_index.md entry {card_model_id} missing from models/model_index.json")

    card_paths = sorted((root / "cards").glob("*.md")) if (root / "cards").exists() else []
    for card_path in card_paths:
        if card_path.name in {"README.md", "card_index.md"}:
            continue
        card_model_id = card_path.stem
        if card_index is not None and card_model_id not in card_index_model_ids:
            errors.append(f"card {card_path.relative_to(root).as_posix()} missing from cards/card_index.md")

    for _, model in model_records:
        model_id = model.get("model_id")
        if not model_id:
            continue
        if model_index and model_id not in indexed_model_ids:
            errors.append(f"model {model_id} missing from models/model_index.json")
        if card_index is not None and model_id not in card_index_model_ids:
            errors.append(f"model {model_id} missing from cards/card_index.md")
        case_count = cases_by_model.get(model_id, 0)
        if case_count < MIN_REGRESSION_CASES_PER_MODEL:
            errors.append(f"model {model_id} has {case_count} regression cases, expected at least {MIN_REGRESSION_CASES_PER_MODEL}")
        present_case_types = case_types_by_model.get(model_id, set())
        for required_case_type in sorted(REQUIRED_REGRESSION_CASE_TYPES):
            if required_case_type not in present_case_types:
                errors.append(f"model {model_id} missing regression case type {required_case_type}")

    return errors


def write_report(root, errors):
    report_path = Path(root) / "reports" / "validation_report.md"
    report_path.parent.mkdir(parents=True, exist_ok=True)
    status = "PASS" if not errors else "FAIL"
    lines = [
        "# Validation Report",
        "",
        f"Status: `{status}`",
        "",
        "Command: `python scripts/validate_model_library.py`",
        "",
    ]
    if errors:
        lines.append("## Errors")
        lines.append("")
        lines.extend(f"- {error}" for error in errors)
    else:
        lines.append("## Result")
        lines.append("")
        lines.append("- JSON 文件可解析。")
        lines.append("- 模型必填字段存在并符合本地 contract 子集。")
        lines.append("- source article、source excerpt、regression case 引用完整。")
        lines.append("- model/card index 引用完整，计数、状态和索引投影一致。")
    report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return report_path


def main():
    root = Path(__file__).resolve().parents[1]
    errors = validate_library(root)
    report_path = write_report(root, errors)
    print(f"validation report written to {report_path}")
    if errors:
        for error in errors:
            print(f"ERROR: {error}")
        return 1
    print("validation passed")
    return 0


if __name__ == "__main__":
    sys.exit(main())
