from __future__ import annotations

import json
import shutil
from pathlib import Path
from typing import Any

from .agent_runtime import AGENTS_END, AGENTS_START
from .portability import remove_unmanaged_aictx_gitignore_lines, strip_aictx_gitattributes_block, strip_aictx_gitignore_block
from .runner_integrations import (
    AICTX_END,
    AICTX_CONFIG_END,
    AICTX_CONFIG_START,
    AICTX_START,
    CLAUDE_GITIGNORE_COMMENT,
    CLAUDE_DIR_GITIGNORE_LINE,
    CLAUDE_MD_GITIGNORE_LINE,
    CODEX_CONFIG_PATH,
    CODEX_HOME,
    COPILOT_FINALIZE_PROMPT_PATH,
    COPILOT_INSTRUCTIONS_PATH,
    COPILOT_PATH_INSTRUCTIONS_PATH,
    COPILOT_RESUME_PROMPT_PATH,
    codex_instructions_path,
    strip_legacy_codex_mcp_block,
)
from .state import ENGINE_HOME, PROJECTS_REGISTRY_PATH, WORKSPACES_DIR, read_json, write_json
from .integrations.mcp_config import remove_global_mcp_config, remove_repo_mcp_config

REPO_HOOK_FILES = [
    Path('.claude/hooks/aictx_session_start.py'),
    Path('.claude/hooks/aictx_user_prompt_submit.py'),
    Path('.claude/hooks/aictx_pre_tool_use.py'),
    Path('.claude/hooks/aictx_refresh_memory_graph.sh'),
]
REPO_OPTIONAL_FILES = [
    COPILOT_INSTRUCTIONS_PATH,
    COPILOT_PATH_INSTRUCTIONS_PATH,
    COPILOT_RESUME_PROMPT_PATH,
    COPILOT_FINALIZE_PROMPT_PATH,
    Path('AGENTS.override.md'),
    Path('CLAUDE.md'),
    Path('AGENTS.md'),
]
CLAUDE_GITIGNORE_LINES = {
    CLAUDE_DIR_GITIGNORE_LINE,
    CLAUDE_MD_GITIGNORE_LINE,
}
CODEX_MANAGED_COMMENT = '# AICTX managed fallback docs for stronger repo instruction loading'
CODEX_MANAGED_LINE = 'project_doc_fallback_filenames = ["CLAUDE.md"]'
CODEX_INSTRUCTIONS_COMMENT = '# AICTX managed mandatory Codex developer instructions'


def _safe_unlink(path: Path, removed: list[str]) -> None:
    if path.exists() or path.is_symlink():
        path.unlink()
        removed.append(str(path))


def _safe_rmtree(path: Path, removed: list[str]) -> None:
    if path.exists():
        shutil.rmtree(path)
        removed.append(str(path))


def _cleanup_empty_parents(path: Path, stop_at: Path | None = None) -> None:
    current = path.parent
    limit = stop_at.resolve() if stop_at else None
    while current.exists():
        if limit and current.resolve() == limit:
            break
        try:
            current.rmdir()
        except OSError:
            break
        current = current.parent


def remove_marked_block(path: Path, start_marker: str = AICTX_START, end_marker: str = AICTX_END) -> bool:
    if not path.exists():
        return False
    text = path.read_text(encoding='utf-8')
    if start_marker not in text or end_marker not in text:
        return False
    start = text.index(start_marker)
    end = text.index(end_marker, start) + len(end_marker)
    head = text[:start].rstrip()
    tail = text[end:].lstrip()
    pieces = []
    if head:
        pieces.append(head)
    if tail:
        pieces.append(tail)
    updated = ('\n\n'.join(pieces)).rstrip()
    if updated:
        path.write_text(updated + '\n', encoding='utf-8')
    else:
        path.unlink()
    return True


def _remove_frontmatter_only_file(path: Path) -> bool:
    if not path.exists():
        return False
    text = path.read_text(encoding='utf-8').strip()
    if not text.startswith('---'):
        return False
    parts = text.split('---', 2)
    if len(parts) == 3 and not parts[2].strip():
        path.unlink()
        return True
    return False


def remove_gitignore_aictx_entries(path: Path) -> bool:
    if not path.exists():
        return False
    original_text = path.read_text(encoding='utf-8')
    text = strip_aictx_gitignore_block(original_text)
    text = remove_unmanaged_aictx_gitignore_lines(text)

    lines = text.splitlines()
    filtered: list[str] = []
    skip_claude_managed_lines = False
    changed = text != original_text
    for line in lines:
        stripped = line.strip()
        if skip_claude_managed_lines:
            if stripped in CLAUDE_GITIGNORE_LINES:
                changed = True
                continue
            skip_claude_managed_lines = False
        if stripped == CLAUDE_GITIGNORE_COMMENT:
            skip_claude_managed_lines = True
            changed = True
            continue
        filtered.append(line)
    if not changed and filtered == lines:
        return False
    if filtered:
        path.write_text('\n'.join(filtered).rstrip() + '\n', encoding='utf-8')
    else:
        path.unlink()
    return True


def remove_gitattributes_aictx_entries(path: Path) -> bool:
    if not path.exists():
        return False
    original_text = path.read_text(encoding='utf-8')
    text = strip_aictx_gitattributes_block(original_text).rstrip()
    if text == original_text.rstrip():
        return False
    if text:
        path.write_text(text + '\n', encoding='utf-8')
    else:
        path.unlink()
    return True


def remove_claude_settings_aictx_entries(path: Path) -> bool:
    if not path.exists():
        return False
    payload = read_json(path, {})
    if not isinstance(payload, dict):
        return False
    hooks = payload.get('hooks')
    if not isinstance(hooks, dict):
        return False

    changed = False
    cleaned_hooks: dict[str, Any] = {}
    for event_name, entries in hooks.items():
        if not isinstance(entries, list):
            cleaned_hooks[event_name] = entries
            continue
        kept_entries = []
        for entry in entries:
            if not isinstance(entry, dict):
                kept_entries.append(entry)
                continue
            entry_hooks = entry.get('hooks')
            if not isinstance(entry_hooks, list):
                kept_entries.append(entry)
                continue
            filtered_commands = []
            removed_any = False
            for hook in entry_hooks:
                if not isinstance(hook, dict):
                    filtered_commands.append(hook)
                    continue
                command = str(hook.get('command') or '')
                if 'aictx_session_start.py' in command or 'aictx_user_prompt_submit.py' in command or 'aictx_pre_tool_use.py' in command:
                    changed = True
                    removed_any = True
                    continue
                filtered_commands.append(hook)
            if filtered_commands:
                updated_entry = dict(entry)
                updated_entry['hooks'] = filtered_commands
                kept_entries.append(updated_entry)
            elif not removed_any:
                kept_entries.append(entry)
            else:
                changed = True
        if kept_entries:
            cleaned_hooks[event_name] = kept_entries
        elif event_name in hooks:
            changed = True
    if not changed:
        return False
    if cleaned_hooks:
        payload['hooks'] = cleaned_hooks
        path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
    else:
        path.unlink()
    return True


def remove_codex_config_aictx_entries(path: Path = CODEX_CONFIG_PATH) -> bool:
    if not path.exists():
        return False
    instructions_path = codex_instructions_path()
    original_text = path.read_text(encoding='utf-8')
    text = original_text
    if AICTX_CONFIG_START in text and AICTX_CONFIG_END in text:
        text = remove_marked_block_text(text, AICTX_CONFIG_START, AICTX_CONFIG_END)
    text = strip_legacy_codex_mcp_block(text)
    lines = text.splitlines()
    managed_lines = {
        CODEX_MANAGED_COMMENT,
        CODEX_MANAGED_LINE,
        CODEX_INSTRUCTIONS_COMMENT,
        f'model_instructions_file = "{instructions_path.as_posix()}"',
    }
    filtered = [line for line in lines if line.strip() not in managed_lines]
    if filtered == original_text.splitlines():
        return False
    while filtered and not filtered[-1].strip():
        filtered.pop()
    if filtered:
        path.write_text('\n'.join(filtered) + '\n', encoding='utf-8')
    else:
        path.unlink()
    return True


def remove_marked_block_text(text: str, start_marker: str, end_marker: str) -> str:
    if start_marker not in text or end_marker not in text:
        return text
    start = text.index(start_marker)
    end = text.index(end_marker, start) + len(end_marker)
    head = text[:start].rstrip()
    tail = text[end:].lstrip()
    pieces = [piece for piece in [head, tail] if piece]
    return ("\n\n".join(pieces).rstrip() + "\n") if pieces else ""


def clean_repo(repo: Path) -> dict[str, Any]:
    repo = repo.expanduser().resolve()
    removed: list[str] = []
    updated: list[str] = []

    engine_dir = repo / '.aictx'
    if engine_dir.exists():
        _safe_rmtree(engine_dir, removed)

    for rel_path in REPO_HOOK_FILES:
        hook_path = repo / rel_path
        if hook_path.exists():
            _safe_unlink(hook_path, removed)
            _cleanup_empty_parents(hook_path, stop_at=repo)

    settings_path = repo / '.claude/settings.json'
    if remove_claude_settings_aictx_entries(settings_path):
        if settings_path.exists():
            updated.append(str(settings_path))
        else:
            removed.append(str(settings_path))
        _cleanup_empty_parents(settings_path, stop_at=repo)

    for rel_path in REPO_OPTIONAL_FILES:
        file_path = repo / rel_path
        if remove_marked_block(file_path, AGENTS_START, AGENTS_END):
            if rel_path == COPILOT_PATH_INSTRUCTIONS_PATH and _remove_frontmatter_only_file(file_path):
                if str(file_path) not in removed:
                    removed.append(str(file_path))
                _cleanup_empty_parents(file_path, stop_at=repo)
                continue
            if file_path.exists():
                updated.append(str(file_path))
            else:
                removed.append(str(file_path))
            _cleanup_empty_parents(file_path, stop_at=repo)
        elif remove_marked_block(file_path, AICTX_START, AICTX_END):
            if rel_path == COPILOT_PATH_INSTRUCTIONS_PATH and _remove_frontmatter_only_file(file_path):
                if str(file_path) not in removed:
                    removed.append(str(file_path))
                _cleanup_empty_parents(file_path, stop_at=repo)
                continue
            if file_path.exists():
                updated.append(str(file_path))
            else:
                removed.append(str(file_path))
            _cleanup_empty_parents(file_path, stop_at=repo)

    gitignore_path = repo / '.gitignore'
    if remove_gitignore_aictx_entries(gitignore_path):
        if gitignore_path.exists():
            updated.append(str(gitignore_path))
        else:
            removed.append(str(gitignore_path))

    mcp_cleanup = remove_repo_mcp_config(repo)
    removed.extend(mcp_cleanup.get('removed', []))
    updated.extend(mcp_cleanup.get('updated', []))

    gitattributes_path = repo / '.gitattributes'
    if remove_gitattributes_aictx_entries(gitattributes_path):
        if gitattributes_path.exists():
            updated.append(str(gitattributes_path))
        else:
            removed.append(str(gitattributes_path))

    return {
        'repo': str(repo),
        'removed': sorted(dict.fromkeys(removed)),
        'updated': sorted(dict.fromkeys(updated)),
    }


def _remove_repo_from_registry(repo: Path) -> None:
    registry = read_json(PROJECTS_REGISTRY_PATH, {'version': 1, 'projects': []})
    if isinstance(registry, dict) and isinstance(registry.get('projects'), list):
        repo_str = str(repo)
        registry['projects'] = [row for row in registry['projects'] if str(row.get('repo_path') or '') != repo_str]
        write_json(PROJECTS_REGISTRY_PATH, registry)


def _workspace_files() -> list[Path]:
    if not WORKSPACES_DIR.exists():
        return []
    return sorted(path for path in WORKSPACES_DIR.glob('*.json') if path.is_file())


def _clean_workspace_file(path: Path, target_repo: Path | None = None) -> list[str]:
    payload = read_json(path, None)
    if not isinstance(payload, dict):
        return []
    removed_roots: list[str] = []
    changed = False
    repo_str = str(target_repo) if target_repo else None
    if repo_str and isinstance(payload.get('repos'), list):
        repos = [item for item in payload['repos'] if str(item) != repo_str]
        if repos != payload['repos']:
            payload['repos'] = repos
            changed = True
    if isinstance(payload.get('roots'), list):
        for root in payload['roots']:
            root_path = Path(str(root)).expanduser().resolve()
            agents_path = root_path / 'AGENTS.md'
            if remove_marked_block(agents_path, AGENTS_START, AGENTS_END):
                removed_roots.append(str(agents_path))
    if changed:
        write_json(path, payload)
    return removed_roots


def clean_repo_and_unregister(repo: Path) -> dict[str, Any]:
    result = clean_repo(repo)
    if ENGINE_HOME.exists():
        _remove_repo_from_registry(repo.expanduser().resolve())
        workspace_updates = []
        for workspace_file in _workspace_files():
            workspace_updates.extend(_clean_workspace_file(workspace_file, target_repo=repo.expanduser().resolve()))
        result['updated'] = sorted(dict.fromkeys(result['updated'] + workspace_updates))
    return result


def uninstall_all() -> dict[str, Any]:
    removed: list[str] = []
    updated: list[str] = []
    repos: list[str] = []

    registry = read_json(PROJECTS_REGISTRY_PATH, {'version': 1, 'projects': []}) if PROJECTS_REGISTRY_PATH.exists() else {'projects': []}
    if isinstance(registry, dict):
        for row in registry.get('projects', []):
            repo_path = Path(str(row.get('repo_path') or '')).expanduser()
            if repo_path and str(repo_path):
                repos.append(str(repo_path.resolve()))

    for workspace_file in _workspace_files():
        payload = read_json(workspace_file, {})
        if isinstance(payload, dict):
            for repo_item in payload.get('repos', []):
                repo_path = Path(str(repo_item)).expanduser()
                if str(repo_path):
                    repos.append(str(repo_path.resolve()))

    for repo_str in sorted(dict.fromkeys(repos)):
        repo_path = Path(repo_str)
        if repo_path.exists():
            result = clean_repo(repo_path)
            removed.extend(result['removed'])
            updated.extend(result['updated'])

    for workspace_file in _workspace_files():
        updated.extend(_clean_workspace_file(workspace_file, target_repo=None))

    instructions_path = codex_instructions_path()
    for file_path in [CODEX_HOME / 'AGENTS.override.md', instructions_path]:
        if remove_marked_block(file_path, AICTX_START, AICTX_END):
            if file_path.exists():
                updated.append(str(file_path))
            else:
                removed.append(str(file_path))
        elif file_path == instructions_path and file_path.exists():
            _safe_unlink(file_path, removed)

    global_mcp_cleanup = remove_global_mcp_config()
    removed.extend(global_mcp_cleanup.get('removed', []))
    updated.extend(global_mcp_cleanup.get('updated', []))

    if remove_codex_config_aictx_entries(CODEX_CONFIG_PATH):
        if CODEX_CONFIG_PATH.exists():
            updated.append(str(CODEX_CONFIG_PATH))
        else:
            removed.append(str(CODEX_CONFIG_PATH))

    if ENGINE_HOME.exists():
        _safe_rmtree(ENGINE_HOME, removed)

    return {
        'repos_cleaned': sorted(dict.fromkeys(repos)),
        'removed': sorted(dict.fromkeys(removed)),
        'updated': sorted(dict.fromkeys(updated)),
    }
