import argparse
import json
import subprocess
from pathlib import Path


PLUGIN_IMPACT_PREFIXES = (
    ".codex-plugin/",
    ".opencode/",
    ".claude-plugin/",
    "skills/",
    "templates/",
    "scripts/",
    ".github/workflows/",
    "docs/",
)
PLUGIN_IMPACT_FILES = {
    "README.md",
    ".prd_plugin/config.json",
    ".prd_plugin/ids/registry.json",
}
COMPANION_PREFIXES = ("docs/", "templates/")
COMPANION_FILES = {"README.md"}
INSTALLED_VERSION_MARKER_SEARCH_PATHS = (
    ".prd_plugin/config.json",
    ".prd_plugin/state/project.json",
    "templates",
)
OPENCODE_MANIFEST_PATH = ".opencode/plugin.json"
CLAUDE_MANIFEST_PATH = ".claude-plugin/plugin.json"
SCRIPT_INSTALL_SCOPE_PATH = "templates/script-install-scope.json"
ALLOWED_SCRIPT_INSTALL_SCOPES = {
    "downstream_runtime",
    "downstream_optional",
    "hub_runtime",
    "plugin_development",
}


def _normalize(path):
    normalized = str(path).replace("\\", "/")
    while normalized.startswith("./"):
        normalized = normalized[2:]
    return normalized


def is_plugin_impacting(path):
    normalized = _normalize(path)
    return normalized in PLUGIN_IMPACT_FILES or normalized.startswith(PLUGIN_IMPACT_PREFIXES)


def _has_companion_artifact(changed_files):
    for path in changed_files:
        normalized = _normalize(path)
        if normalized in COMPANION_FILES or normalized.startswith(COMPANION_PREFIXES):
            return True
    return False


def _has_skill_change(changed_files):
    return any(_normalize(path).startswith("skills/") for path in changed_files)


def _release_versions(releases):
    if not releases:
        return set()
    return {
        str(release.get("version"))
        for release in releases.get("releases", [])
        if isinstance(release, dict) and release.get("version")
    }


def _installed_version_marker_finding(head_version, markers):
    if not head_version or not markers:
        return None

    mismatched = [path for path, value in markers.items() if str(value) != str(head_version)]
    if not mismatched:
        return None

    return {
        "severity": "high",
        "summary": "Installed version markers must match plugin manifest version.",
        "required_action": f"Update {', '.join(mismatched)} to {head_version}.",
    }


def _installed_version_from_data(data):
    if not isinstance(data, dict):
        return None
    plugin = data.get("plugin", {})
    if not isinstance(plugin, dict):
        return None
    installed_version = plugin.get("installed_version")
    if installed_version is None:
        return None
    return str(installed_version)


def _marker_candidate_files(root, search_paths=INSTALLED_VERSION_MARKER_SEARCH_PATHS):
    root = Path(root)
    for search_path in search_paths:
        path = root / search_path
        if path.is_file():
            yield path
        elif path.is_dir():
            yield from sorted(path.rglob("*.json"))


def discover_installed_version_markers(root=".", search_paths=INSTALLED_VERSION_MARKER_SEARCH_PATHS):
    root = Path(root)
    markers = {}
    for path in _marker_candidate_files(root, search_paths):
        data = json.loads(path.read_text(encoding="utf-8-sig"))
        installed_version = _installed_version_from_data(data)
        if installed_version is not None:
                markers[path.relative_to(root).as_posix()] = installed_version

    for manifest_rel in (OPENCODE_MANIFEST_PATH, CLAUDE_MANIFEST_PATH):
        manifest = root / manifest_rel
        if manifest.is_file():
            try:
                data = json.loads(manifest.read_text(encoding="utf-8-sig"))
            except json.JSONDecodeError:
                continue
            version = data.get("version") if isinstance(data, dict) else None
            if version is not None:
                markers[manifest_rel] = str(version)
    return markers


def load_script_install_scope(root=".", manifest_path=SCRIPT_INSTALL_SCOPE_PATH):
    path = Path(root) / manifest_path
    return json.loads(path.read_text(encoding="utf-8-sig"))


def discover_plugin_scripts(root="."):
    scripts_dir = Path(root) / "scripts"
    if not scripts_dir.exists():
        return []
    return sorted(path.name for path in scripts_dir.glob("*.py") if path.is_file())


def _invalid_script_scope_findings(manifest):
    findings = []
    scripts = manifest.get("scripts", {})
    if not isinstance(scripts, dict):
        return [
            {
                "severity": "high",
                "summary": "Script install-scope manifest must contain a scripts object.",
                "required_action": f"Fix {SCRIPT_INSTALL_SCOPE_PATH}.",
            }
        ]

    for script_name, metadata in sorted(scripts.items()):
        if not isinstance(metadata, dict):
            findings.append(
                {
                    "severity": "high",
                    "summary": "Script install-scope manifest entries must be objects.",
                    "required_action": f"Fix metadata for {script_name} in {SCRIPT_INSTALL_SCOPE_PATH}.",
                }
            )
            continue

        install_scope = metadata.get("install_scope")
        if install_scope not in ALLOWED_SCRIPT_INSTALL_SCOPES:
            findings.append(
                {
                    "severity": "high",
                    "summary": "Script install-scope manifest contains an invalid install_scope.",
                    "required_action": f"Set {script_name}.install_scope to one of {', '.join(sorted(ALLOWED_SCRIPT_INSTALL_SCOPES))}.",
                }
            )

        if not isinstance(metadata.get("installed_by_default"), bool):
            findings.append(
                {
                    "severity": "high",
                    "summary": "Script install-scope manifest entries must declare installed_by_default as a boolean.",
                    "required_action": f"Set {script_name}.installed_by_default in {SCRIPT_INSTALL_SCOPE_PATH}.",
                }
            )

    return findings


def analyze_script_install_scope(root="."):
    root = Path(root)
    try:
        manifest = load_script_install_scope(root)
    except FileNotFoundError:
        return [
            {
                "severity": "high",
                "summary": "Script install-scope manifest is missing.",
                "required_action": f"Add {SCRIPT_INSTALL_SCOPE_PATH} before changing scripts or install behavior.",
            }
        ]

    findings = _invalid_script_scope_findings(manifest)
    scripts = manifest.get("scripts", {}) if isinstance(manifest.get("scripts", {}), dict) else {}
    actual_scripts = set(discover_plugin_scripts(root))
    classified_scripts = set(scripts)

    missing = sorted(actual_scripts - classified_scripts)
    if missing:
        findings.append(
            {
                "severity": "high",
                "summary": "Top-level plugin scripts must be classified before release.",
                "required_action": f"Add {', '.join(missing)} to {SCRIPT_INSTALL_SCOPE_PATH}.",
            }
        )

    stale = sorted(classified_scripts - actual_scripts)
    if stale:
        findings.append(
            {
                "severity": "medium",
                "summary": "Script install-scope manifest lists scripts that do not exist.",
                "required_action": f"Remove {', '.join(stale)} from {SCRIPT_INSTALL_SCOPE_PATH}.",
            }
        )

    skeleton_scripts_dir = root / "templates" / "repo-skeleton" / "scripts"
    if skeleton_scripts_dir.exists():
        default_install_scripts = {
            script_name
            for script_name, metadata in scripts.items()
            if isinstance(metadata, dict) and metadata.get("installed_by_default") is True
        }
        skeleton_scripts = {
            path.name
            for path in skeleton_scripts_dir.glob("*.py")
            if path.is_file()
        }
        blocked = sorted(skeleton_scripts - default_install_scripts)
        if blocked:
            findings.append(
                {
                    "severity": "high",
                    "summary": "Repo skeleton contains scripts that are not allowed for default downstream install.",
                    "required_action": f"Remove {', '.join(blocked)} from templates/repo-skeleton/scripts or mark it installed_by_default in {SCRIPT_INSTALL_SCOPE_PATH}.",
                }
            )

    return findings


def analyze_release_hygiene(changed_files, base_plugin, head_plugin, releases=None, head_opencode_manifest=None, head_claude_manifest=None):
    plugin_changes = [path for path in changed_files if is_plugin_impacting(path)]
    findings = []

    if not plugin_changes:
        return findings

    base_version = base_plugin.get("version")
    head_version = head_plugin.get("version")
    if base_version == head_version:
        findings.append(
            {
                "severity": "high",
                "summary": "Plugin-impacting changes require a version bump.",
                "required_action": "Update .codex-plugin/plugin.json version.",
            }
        )
    elif head_version not in _release_versions(releases):
        findings.append(
            {
                "severity": "high",
                "summary": "Version bump requires matching release metadata.",
                "required_action": f"Add {head_version} to .prd_plugin/state/releases.json.",
            }
        )

    if head_opencode_manifest is not None:
        opencode_version = head_opencode_manifest.get("version")
        if opencode_version is not None and str(opencode_version) != str(head_version):
            findings.append(
                {
                    "severity": "high",
                    "summary": "Opencode plugin manifest version must match codex plugin manifest version.",
                    "required_action": f"Update {OPENCODE_MANIFEST_PATH} to {head_version}.",
                }
            )

    if head_claude_manifest is not None:
        claude_version = head_claude_manifest.get("version")
        if claude_version is not None and str(claude_version) != str(head_version):
            findings.append(
                {
                    "severity": "high",
                    "summary": "Claude Code plugin manifest version must match codex plugin manifest version.",
                    "required_action": f"Update {CLAUDE_MANIFEST_PATH} to {head_version}.",
                }
            )

    marker_finding = _installed_version_marker_finding(
        head_version,
        head_plugin.get("_installed_version_markers", {}),
    )
    if marker_finding:
        findings.append(marker_finding)

    if _has_skill_change(changed_files) and not _has_companion_artifact(changed_files):
        findings.append(
            {
                "severity": "medium",
                "summary": "Skill changes should update related docs or templates.",
                "required_action": "Update README/docs/templates or explain why no companion artifact changed.",
            }
        )

    return findings


def _run_git(args):
    result = subprocess.run(["git", *args], check=True, capture_output=True, text=True)
    return result.stdout.strip()


def _try_run_git(args):
    try:
        return _run_git(args)
    except subprocess.CalledProcessError:
        return None


def default_base_ref():
    prior_version_tag = _try_run_git(
        ["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", "HEAD^"]
    )
    return prior_version_tag or "HEAD~1"


def _load_plugin_at_ref(ref):
    content = _run_git(["show", f"{ref}:.codex-plugin/plugin.json"])
    return json.loads(content)


def _load_opencode_manifest_from_worktree(path=OPENCODE_MANIFEST_PATH):
    manifest_path = Path(path)
    if not manifest_path.exists():
        return None
    return json.loads(manifest_path.read_text(encoding="utf-8-sig"))


def _load_claude_manifest_from_worktree(path=CLAUDE_MANIFEST_PATH):
    manifest_path = Path(path)
    if not manifest_path.exists():
        return None
    return json.loads(manifest_path.read_text(encoding="utf-8-sig"))


def _load_plugin_from_worktree(path=".codex-plugin/plugin.json"):
    return json.loads(Path(path).read_text(encoding="utf-8-sig"))


def _load_releases_from_worktree(path=".prd_plugin/state/releases.json"):
    release_path = Path(path)
    if not release_path.exists():
        return {"releases": []}
    return json.loads(release_path.read_text(encoding="utf-8-sig"))


def _load_installed_version_markers():
    return discover_installed_version_markers(".")


def _changed_files(base_ref):
    output = _try_run_git(["diff", "--name-only", f"{base_ref}...HEAD"])
    if output is None:
        output = _try_run_git(["diff", "--name-only", f"{base_ref}", "HEAD"])
    if output is None:
        output = _try_run_git(["status", "--short"])
        if output:
            return [line[3:].strip() for line in output.splitlines() if len(line) > 3]
        return []
    if not output:
        return []
    return [line.strip() for line in output.splitlines() if line.strip()]


def format_markdown(findings, changed_files, base_version, head_version):
    lines = [
        "# Release Hygiene Report",
        "",
        f"Base version: `{base_version}`",
        f"Head version: `{head_version}`",
        f"Changed files checked: {len(changed_files)}",
        "",
    ]

    if findings:
        lines.extend(["## Findings", ""])
        for finding in findings:
            lines.append(f"- `{finding['severity']}`: {finding['summary']} {finding['required_action']}")
    else:
        lines.extend(["## Findings", "", "- None"])

    return "\n".join(lines) + "\n"


def main():
    parser = argparse.ArgumentParser(description="Check PRD Plugin release hygiene.")
    parser.add_argument(
        "--base-ref",
        help="Base git ref for comparison. Defaults to the prior version tag, then HEAD~1.",
    )
    parser.add_argument("--output", help="Optional Markdown output path.")
    parser.add_argument(
        "--no-fail",
        action="store_true",
        help="Report findings without exiting non-zero.",
    )
    args = parser.parse_args()
    base_ref = args.base_ref or default_base_ref()

    changed_files = _changed_files(base_ref)
    warnings = []
    base_available = True
    try:
        base_plugin = _load_plugin_at_ref(base_ref)
    except (subprocess.CalledProcessError, json.JSONDecodeError):
        base_available = False
        base_plugin = _load_plugin_from_worktree()
        warnings.append(f"Could not read .codex-plugin/plugin.json at {base_ref}; release hygiene is inconclusive.")
    head_plugin = _load_plugin_from_worktree()
    head_plugin["_installed_version_markers"] = _load_installed_version_markers()
    head_opencode_manifest = _load_opencode_manifest_from_worktree()
    head_claude_manifest = _load_claude_manifest_from_worktree()
    releases = _load_releases_from_worktree()
    findings = (
        analyze_release_hygiene(
            changed_files,
            base_plugin,
            head_plugin,
            releases,
            head_opencode_manifest=head_opencode_manifest,
            head_claude_manifest=head_claude_manifest,
        )
        if base_available
        else []
    )
    findings.extend(analyze_script_install_scope("."))
    markdown = format_markdown(
        findings,
        changed_files,
        base_plugin.get("version"),
        head_plugin.get("version"),
    )
    if warnings:
        warning_text = "\n".join(f"- {warning}" for warning in warnings)
        markdown += f"\n## Warnings\n\n{warning_text}\n"

    if args.output:
        Path(args.output).parent.mkdir(parents=True, exist_ok=True)
        Path(args.output).write_text(markdown, encoding="utf-8")
    else:
        print(markdown, end="")

    if findings and not args.no_fail:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
