#!/usr/bin/env python3
"""Migration helper to convert profiles.json from version 1 to version 2 environments shape.

This script preserves existing keychain references (auth.id) and maps old profile
names as aliases so that existing IDE configurations continue to function.
"""

import os
import sys
import json
import argparse
import shutil
import tempfile

# Resolve path to import gitea_config
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
if PROJECT_ROOT not in sys.path:
    sys.path.insert(0, PROJECT_ROOT)

import gitea_config


# Defaults emit *canonical* operation names only. Shorthand that is not in
# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``)
# is silently dropped by the production loader and must never appear here.
AUTHOR_DEFAULT_ALLOWED = [
    "gitea.read",
    "gitea.branch.create",
    "gitea.repo.commit",
    "gitea.branch.push",
    "gitea.pr.create",
    "gitea.pr.comment",
]
AUTHOR_DEFAULT_FORBIDDEN = [
    "gitea.pr.approve",
    "gitea.pr.request_changes",
    "gitea.pr.merge",
]
REVIEWER_DEFAULT_ALLOWED = [
    "gitea.read",
    "gitea.pr.review",
    "gitea.pr.comment",
    "gitea.pr.approve",
    "gitea.pr.request_changes",
    "gitea.pr.merge",
]
REVIEWER_DEFAULT_FORBIDDEN = [
    "gitea.branch.create",
    "gitea.repo.commit",
    "gitea.branch.push",
    "gitea.pr.create",
]
# Required reconciler ops (read + pr.close) plus recommended comment/close and
# branch.delete for guarded merged-PR cleanup. All names must normalize via
# gitea_config.normalize_operation without being dropped.
RECONCILER_DEFAULT_ALLOWED = [
    "gitea.read",
    "gitea.pr.close",
    "gitea.pr.comment",
    "gitea.issue.comment",
    "gitea.issue.close",
    "gitea.branch.delete",
]
RECONCILER_DEFAULT_FORBIDDEN = [
    "gitea.pr.approve",
    "gitea.pr.merge",
    "gitea.pr.review",
    "gitea.pr.create",
    "gitea.branch.push",
    "gitea.repo.commit",
]

# Migration-only expansions for common shorthands that are *not* in
# GITEA_OPERATION_ALIASES. Emitted output is always the canonical form so a
# second canonicalize pass is a no-op (idempotent).
_MIGRATION_ONLY_ALIASES = {
    "pr.close": "gitea.pr.close",
    "pr.comment": "gitea.pr.comment",
    "issue.close": "gitea.issue.close",
    "branch.delete": "gitea.branch.delete",
}

# Reconciler required ops that must survive migration (from reconciler_profile).
RECONCILER_REQUIRED_CANONICAL = ("gitea.read", "gitea.pr.close")


def canonicalize_operation(op: str) -> str:
    """Return a canonical operation name accepted by the production loader.

    Fail closed on unknown/ambiguous spellings so required permissions cannot
    be silently dropped by ``check_operation`` later.
    """
    if not isinstance(op, str) or not op.strip():
        raise ValueError("operation must be a non-empty string (fail closed)")
    op = op.strip()
    try:
        return gitea_config.normalize_operation(op)
    except gitea_config.ConfigError:
        pass
    if op in _MIGRATION_ONLY_ALIASES:
        return _MIGRATION_ONLY_ALIASES[op]
    raise ValueError(
        f"operation {op!r} cannot be canonicalized for migration "
        "(unknown/ambiguous; fail closed — production loader would drop it)"
    )


def canonicalize_operations(ops, *, context: str = "operations") -> list[str]:
    """Canonicalize a list of operations; preserve order, drop duplicates."""
    if not isinstance(ops, list):
        raise ValueError(f"{context} must be a list (fail closed)")
    out: list[str] = []
    seen: set[str] = set()
    for entry in ops:
        canon = canonicalize_operation(entry)
        if canon not in seen:
            seen.add(canon)
            out.append(canon)
    return out


def _assert_reconciler_required_survive(allowed: list[str], profile_name: str) -> None:
    """Fail visibly when migration would leave a reconciler without required ops."""
    missing = [op for op in RECONCILER_REQUIRED_CANONICAL if op not in set(allowed)]
    if missing:
        raise ValueError(
            f"Profile '{profile_name}' (reconciler) is missing required "
            f"operation(s) after migration: {missing}. Refusing to emit a "
            "profile that would silently fail pr.close / read (fail closed)."
        )


def infer_role(name, execution_profile):
    """Return the unambiguous role for a legacy profile name, or None."""
    haystack = f"{name} {execution_profile or ''}".lower()
    if "reconciler" in haystack:
        return "reconciler"
    has_author = "author" in haystack
    has_reviewer = "reviewer" in haystack
    if has_author == has_reviewer:
        return None
    return "reviewer" if has_reviewer else "author"


def migration_summary(v2_data):
    """Return a redacted summary of the migrated config."""
    environments = v2_data.get("environments", {})
    service_count = 0
    identity_count = 0
    for env in environments.values():
        services = env.get("services", {})
        service_count += len(services)
        for service in services.values():
            identity_count += len(service.get("identities", {}))
    return {
        "version": v2_data.get("version"),
        "environments": len(environments),
        "services": service_count,
        "identities": identity_count,
        "aliases": len(v2_data.get("aliases", {})),
    }


def migrate_v1_to_v2(v1_data):
    """Convert version 1 profiles.json format to version 2 environments format."""
    environments = {}
    aliases = {}

    profiles = v1_data.get("profiles", {})
    if not isinstance(profiles, dict):
        raise ValueError("Malformed input: 'profiles' field must be a JSON object")

    for name, prof in profiles.items():
        if not isinstance(prof, dict):
            raise ValueError(f"Malformed input: profile '{name}' must be a JSON object")

        # Infer environment and identity name
        if "-" in name:
            parts = name.split("-", 1)
            env_name = parts[0]
            ident_name = parts[1]
        else:
            env_name = name
            ident_name = "author"

        # Determine role and identity based on name / execution_profile.
        # Ambiguous profiles may still migrate only when they carry explicit
        # permissions; otherwise role-based defaults could widen permissions.
        exec_prof = prof.get("execution_profile") or ""
        role = infer_role(name, exec_prof)
        if role == "reviewer":
            ident_name = "reviewer"
        elif role == "author":
            ident_name = "author"
        elif role == "reconciler":
            ident_name = "reconciler"
        else:
            role = prof.get("role")
            if role not in (None, "author", "reviewer", "reconciler"):
                raise ValueError(
                    f"Profile '{name}' has unsupported role {role!r}"
                )

        # Construct identity block
        identity_data = {
            "username": prof.get("username"),
            "auth": prof.get("auth"),
        }
        if role:
            identity_data["role"] = role
        if prof.get("execution_profile"):
            identity_data["execution_profile"] = prof["execution_profile"]

        # Set audit label (default to old name to preserve context)
        identity_data["audit_label"] = prof.get("audit_label") or name

        has_allowed = "allowed_operations" in prof
        has_forbidden = "forbidden_operations" in prof
        if has_allowed != has_forbidden:
            raise ValueError(
                f"Profile '{name}' must define both allowed_operations and "
                "forbidden_operations, or neither (fail closed)"
            )
        if has_allowed:
            allowed = prof.get("allowed_operations")
            forbidden = prof.get("forbidden_operations")
            if not isinstance(allowed, list) or not isinstance(forbidden, list):
                raise ValueError(
                    f"Profile '{name}' operation fields must be lists"
                )
            try:
                identity_data["allowed_operations"] = canonicalize_operations(
                    allowed, context=f"profile '{name}' allowed_operations"
                )
                identity_data["forbidden_operations"] = canonicalize_operations(
                    forbidden, context=f"profile '{name}' forbidden_operations"
                )
            except ValueError as exc:
                raise ValueError(f"Profile '{name}': {exc}") from exc
        elif role == "author":
            identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
            identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
        elif role == "reviewer":
            identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
            identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN)
        elif role == "reconciler":
            identity_data["allowed_operations"] = list(RECONCILER_DEFAULT_ALLOWED)
            identity_data["forbidden_operations"] = list(RECONCILER_DEFAULT_FORBIDDEN)
        else:
            raise ValueError(
                f"Profile '{name}' has no explicit operation lists and no "
                "unambiguous author/reviewer role marker (fail closed)"
            )

        if role == "reconciler":
            _assert_reconciler_required_survive(
                identity_data["allowed_operations"], name
            )

        # Nest inside environments/services structure
        env = environments.setdefault(env_name, {})
        services = env.setdefault("services", {})
        gitea_svc = services.setdefault("gitea", {})

        # Copy service-level attributes
        if prof.get("base_url"):
            gitea_svc["base_url"] = prof["base_url"]
        if prof.get("default_owner"):
            gitea_svc["default_owner"] = prof["default_owner"]
        if prof.get("default_repo"):
            gitea_svc["default_repo"] = prof["default_repo"]

        identities = gitea_svc.setdefault("identities", {})
        identities[ident_name] = identity_data

        # Alias resolution targets
        alias_target = f"{env_name}.gitea.{ident_name}"
        if name != alias_target:
            aliases[name] = alias_target

        # Extra convenience alias for standard old-profile compatibility (e.g. prgs-author)
        convenience_alias = f"{env_name}-{ident_name}"
        if convenience_alias != alias_target and convenience_alias not in aliases:
            aliases[convenience_alias] = alias_target

    v2_data = {
        "version": 2,
        "environments": environments,
        "aliases": aliases
    }
    return v2_data


def validate_v2_data(v2_data):
    """Validate generated v2 structure using gitea_config parser."""
    fd, temp_path = tempfile.mkstemp(suffix=".json")
    os.close(fd)
    try:
        with open(temp_path, "w") as f:
            json.dump(v2_data, f)
        # Attempt to load using load_config to run all validation rules
        gitea_config.load_config(temp_path)
        return True
    except Exception as e:
        raise ValueError(f"Generated v2 config failed validation: {e}")
    finally:
        try:
            os.remove(temp_path)
        except OSError:
            pass


def main():
    parser = argparse.ArgumentParser(
        description="Migrate profiles.json from version 1 to version 2 environments shape."
    )
    parser.add_argument(
        "-i", "--input",
        default=gitea_config.DEFAULT_CONFIG_PATH,
        help="Path to the version 1 profiles.json file (default: ~/.config/gitea-tools/profiles.json)"
    )
    parser.add_argument(
        "-o", "--output",
        help="Path to write the migrated version 2 profiles.json file (default: overwrite input)"
    )
    parser.add_argument(
        "-w", "--write",
        action="store_true",
        help="Actually write the migrated config and create a backup (default is dry-run)"
    )
    parser.add_argument(
        "--backup",
        help="Path to write the backup file (default: <input_path>.bak)"
    )

    args = parser.parse_args()

    input_path = os.path.abspath(args.input)
    output_path = os.path.abspath(args.output or input_path)
    backup_path = args.backup or f"{input_path}.bak"

    if not os.path.isfile(input_path):
        print(f"Error: Input file not found: {input_path}", file=sys.stderr)
        sys.exit(1)

    try:
        with open(input_path, "r") as f:
            v1_data = json.load(f)
    except json.JSONDecodeError as e:
        print(f"Error: Input file is not valid JSON: {e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error reading input file: {e}", file=sys.stderr)
        sys.exit(1)

    # Validate version
    version = v1_data.get("version")
    if version is not None and version != 1:
        print(f"Error: Unsupported profiles.json version: {version}. Expected version 1.", file=sys.stderr)
        sys.exit(1)

    try:
        v2_data = migrate_v1_to_v2(v1_data)
        validate_v2_data(v2_data)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)

    if not args.write:
        print("=== DRY-RUN MODE (No files modified) ===")
        print("Generated v2 config validated successfully.")
        print("Only aggregate counts are shown.")
        summary = migration_summary(v2_data)
        print("Summary:")
        print(f"  version: {summary['version']}")
        print(f"  environments: {summary['environments']}")
        print(f"  services: {summary['services']}")
        print(f"  identities: {summary['identities']}")
        print(f"  aliases: {summary['aliases']}")
        sys.exit(0)

    # Write Mode: Create Backup first
    try:
        print(f"Creating backup: {backup_path}")
        shutil.copy2(input_path, backup_path)
    except Exception as e:
        print(f"Error creating backup: {e}", file=sys.stderr)
        sys.exit(1)

    # Write migrated config
    try:
        print(f"Writing migrated version 2 config: {output_path}")
        # Ensure target directory exists
        os.makedirs(os.path.dirname(output_path), exist_ok=True)
        with open(output_path, "w") as f:
            json.dump(v2_data, f, indent=2)
            f.write("\n")
        print("Migration completed successfully!")
    except Exception as e:
        print(f"Error writing output file: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
