"""Aider Gitea.

A code automation tool that integrates Gitea with Aider to automatically solve issues.

This program monitors your [Gitea](https://about.gitea.com/) repository for issues with the 'aider' label.
When such an issue is found, it:

1. Creates a new branch.
2. Invokes [Aider](https://aider.chat/) to solve the issue using a Large-Language Model.
3. Runs tests and code quality checks.
4. Creates a pull request with the solution.

Inspired by [the AI workflows](https://github.com/oscoreio/ai-workflows/)
project.

## Usage

An application token must be supplied for the `gitea_token` secret. This must
have the following permissions:

- `read:issue`: To be able to read issues on the specified repository.
- `write:repository`: To be able to create pull requests.
- `read:user`: Needed to iterate all user's repositories.

### Command Line

```bash
# Run with default settings
python -m aider_gitea

# Specify custom repository and owner
python -m aider_gitea --owner myorg --repo myproject

# Use a custom Gitea URL
python -m aider_gitea --gitea-url https://gitea.example.com

# Specify a different base branch
python -m aider_gitea --base-branch develop
```

### Python API

```python
from aider_gitea import solve_issue_in_repository
from pathlib import Path

# Solve an issue programmatically
args = argparse.Namespace(
    gitea_url="https://gitea.example.com",
    owner="myorg",
    repo="myproject",
    base_branch="main"
)

solve_issue_in_repository(
    args,
    Path("/path/to/repo"),
    "issue-123-fix-bug",
    "Fix critical bug",
    "The application crashes when processing large files",
    "123"
)
```

### Environment Configuration

The tool uses environment variables for sensitive information:
- `GITEA_TOKEN`: Your Gitea API token
- `LLM_API_KEY`: API key for the language model used by Aider
```
"""

import dataclasses
import logging
import re
import subprocess
import sys
import tempfile
from pathlib import Path

from . import secrets
from ._version import __version__  # noqa: F401

logger = logging.getLogger(__name__)


@dataclasses.dataclass(frozen=True)
class RepositoryConfig:
    gitea_url: str
    owner: str
    repo: str
    base_branch: str

    def repo_url(self) -> str:
        return f'{self.gitea_url}:{self.owner}/{self.repo}.git'.replace(
            'https://',
            'git@',
        )


@dataclasses.dataclass(frozen=True)
class IssueResolution:
    success: bool
    pull_request_url: str | None = None
    pull_request_id: str | None = None


def generate_branch_name(issue_number: str, issue_title: str) -> str:
    """Create a branch name by sanitizing the issue title.

    Non-alphanumeric characters (except spaces) are removed,
    the text is lowercased, and spaces are replaced with dashes.

    Args:
        issue_number: The issue number to include in the branch name.
        issue_title: The issue title to sanitize and include in the branch name.

    Returns:
        A sanitized branch name combining the issue number and title.
    """
    sanitized = re.sub(r'[^0-9a-zA-Z ]+', '', issue_title)
    parts = ['issue', str(issue_number), *sanitized.lower().split()]
    return '-'.join(parts)


def bash_cmd(*commands: str) -> str:
    commands = ('set -e', *commands)
    return 'bash -c "' + ';'.join(commands) + '"'


AIDER_TEST = bash_cmd(
    'virtualenv venv',
    'source venv/bin/activate',
    'pip install -e .',
    'pytest test',
)

RUFF_FORMAT_AND_AUTO_FIX = bash_cmd(
    'ruff format',
    'ruff check --fix --ignore RUF022 --ignore PGH004',
    'ruff format',
    'ruff check --fix --ignore RUF022 --ignore PGH004',
)

AIDER_LINT = bash_cmd(
    RUFF_FORMAT_AND_AUTO_FIX,
    'ruff format',
    'ruff check --ignore RUF022 --ignore PGH004',
)


LLM_MESSAGE_FORMAT = """
{issue}

# Solution Details

For code tasks:

1. Create a plan for how to solve the issue.
2. Write unit tests that proves that your solution works.
3. Then, solve the issue by writing the required code.
"""

MODEL = None


def create_aider_command(issue: str) -> list[str]:
    l = [
        'aider',
        '--chat-language',
        'english',
        '--no-stream',
        '--no-analytics',
        '--test-cmd',
        AIDER_TEST,
        '--lint-cmd',
        AIDER_LINT,
        '--auto-test',
        '--no-auto-lint',
        '--read',
        'CONVENTIONS.md',
        '--message',
        LLM_MESSAGE_FORMAT.format(issue=issue),
        '--yes',
    ]

    for key in secrets.llm_api_keys():
        l += ['--api-key', key]

    if True:
        l.append('--cache-prompts')

    if False:
        l.append('--architect')

    if MODEL:
        l.append('--model')
        l.append(MODEL)

    return l


def get_commit_messages(cwd: Path, base_branch: str, current_branch: str) -> list[str]:
    """Get commit messages between base branch and current branch.

    Args:
        cwd: The current working directory (repository path).
        base_branch: The name of the base branch to compare against.
        current_branch: The name of the current branch to check for commits.

    Returns:
        A string containing all commit messages, one per line.
    """
    try:
        result = subprocess.run(
            ['git', 'log', f'{base_branch}..{current_branch}', '--pretty=format:%s'],
            check=True,
            cwd=cwd,
            capture_output=True,
            text=True,
        )
        return list(reversed(result.stdout.strip().split('\n')))
    except subprocess.CalledProcessError:
        logger.exception(f'Failed to get commit messages on branch {current_branch}')
        return []


def push_changes(
    repository_config: RepositoryConfig,
    cwd: Path,
    branch_name: str,
    issue_number: str,
    issue_title: str,
    gitea_client,
) -> IssueResolution:
    # Check if there are any commits on the branch before pushing
    if not has_commits_on_branch(cwd, repository_config.base_branch, branch_name):
        logger.info('No commits made on branch %s, skipping push', branch_name)
        return IssueResolution(False)

    # Get commit messages for PR description
    commit_messages = get_commit_messages(
        cwd, repository_config.base_branch, branch_name,
    )
    description = f'This pull request resolves #{issue_number}\n\n'

    if commit_messages:
        description += '## Commit Messages\n\n'
        for message in commit_messages:
            description += f'- {message}\n'

    # First push the branch without creating a PR
    cmd = ['git', 'push', 'origin', branch_name, '--force']
    push_success = run_cmd(cmd, cwd, check=False)

    if not push_success:
        error_message = f"Failed to push branch `{branch_name}`. The changes could not be uploaded to the repository."
        logger.error(error_message)
        try:
            gitea_client.create_issue_comment(
                owner=owner,
                repo=repo,
                issue_number=issue_number,
                body=f"❌ **Automated Solution Failed**\n\n{error_message}\n\nPlease check repository permissions and try again."
            )
        except Exception as e:
            logger.exception(f"Failed to comment on issue #{issue_number} after push failure: {e}")
        return False

    # Then create the PR with the aider label
    pr_response = gitea_client.create_pull_request(
        owner=repository_config.owner,
        repo=repository_config.repo,
        title=issue_title,
        body=description,
        head=branch_name,
        base=repository_config.base_branch,
        labels=['aider'],
    )

    # Extract PR number and URL if available
    return IssueResolution(
        True, str(pr_response.get('number')), pr_response.get('html_url'),
    )


def has_commits_on_branch(cwd: Path, base_branch: str, current_branch: str) -> bool:
    """Check if there are any commits on the current branch that aren't in the base branch.

    Args:
        cwd: The current working directory (repository path).
        base_branch: The name of the base branch to compare against.
        current_branch: The name of the current branch to check for commits.

    Returns:
        True if there are commits on the current branch not in the base branch, False otherwise.
    """
    try:
        commit_messages = get_commit_messages(cwd, base_branch, current_branch)
        return bool(list(commit_messages))
    except Exception:
        logger.exception('Failed to check commits on branch %s', current_branch)
        return False


def run_cmd(cmd: list[str], cwd: Path | None = None, check=True) -> bool:
    """Run a shell command and return its success status.

    Args:
        cmd: The command to run as a list of strings.
        cwd: The directory to run the command in.
        check: Whether to raise an exception if the command fails.

    Returns:
        True if the command succeeded, False otherwise.
    """
    result = subprocess.run(cmd, check=check, cwd=cwd)
    return result.returncode == 0


SKIP_AIDER = False


def solve_issue_in_repository(
    repository_config: RepositoryConfig,
    tmpdirname: Path,
    branch_name: str,
    issue_title: str,
    issue_description: str,
    issue_number: str,
    gitea_client,
) -> IssueResolution:
    logger.info('### %s #####', issue_title)

    # Setup repository
    run_cmd(['git', 'clone', repository_config.repo_url(), tmpdirname])
    run_cmd(['bash', '-c', AIDER_TEST], tmpdirname)
    run_cmd(['git', 'checkout', repository_config.base_branch], tmpdirname)
    run_cmd(['git', 'checkout', '-b', branch_name], tmpdirname)

    # Run initial ruff pass before aider
    run_cmd(['bash', '-c', RUFF_FORMAT_AND_AUTO_FIX], tmpdirname, check=False)
    run_cmd(['git', 'add', '.'], tmpdirname)
    run_cmd(['git', 'commit', '-m', 'Initial ruff pass'], tmpdirname, check=False)

    # Save the commit hash after ruff but before aider
    result = subprocess.run(
        ['git', 'rev-parse', 'HEAD'],
        check=True,
        cwd=tmpdirname,
        capture_output=True,
        text=True,
    )
    pre_aider_commit = result.stdout.strip()

    # Run aider
    issue_content = f'# {issue_title}\n{issue_description}'
    if not SKIP_AIDER:
        succeeded = run_cmd(
            create_aider_command(issue_content),
            tmpdirname,
            check=False,
        )
    else:
        logger.warning('Skipping aider command (for testing)')
        succeeded = True
    if not succeeded:
        logger.error('Aider invocation failed for issue #%s', issue_number)
        return IssueResolution(False)

    # Auto-fix standard code quality stuff after aider
    run_cmd(['bash', '-c', RUFF_FORMAT_AND_AUTO_FIX], tmpdirname, check=False)
    run_cmd(['git', 'add', '.'], tmpdirname)
    run_cmd(['git', 'commit', '-m', 'Ruff after aider'], tmpdirname, check=False)

    # Check if aider made any changes beyond the initial ruff pass
    result = subprocess.run(
        ['git', 'diff', pre_aider_commit, 'HEAD', '--name-only'],
        check=True,
        cwd=tmpdirname,
        capture_output=True,
        text=True,
    )
    files_changed = result.stdout.strip()

    if not files_changed and not SKIP_AIDER:
        logger.info(
            'Aider did not make any changes beyond the initial ruff pass for issue #%s',
            issue_number,
        )
        return IssueResolution(False)

    # Push changes
    return push_changes(
        repository_config,
        tmpdirname,
        branch_name,
        issue_number,
        issue_title,
        gitea_client,
    )


def solve_issues_in_repository(
    repository_config: RepositoryConfig, client, seen_issues_db,
):
    """Process all open issues with the 'aider' label.

    Args:
        repository_config: Command line arguments.
        client: The Gitea client instance.
        seen_issues_db: Database of previously processed issues.
    """
    try:
        issues = client.get_issues(repository_config.owner, repository_config.repo)
    except Exception:
        logger.exception('Failed to retrieve issues')
        sys.exit(1)

    if not issues:
        logger.info('No issues found for %s', repository_config.repo)
        return

    for issue in issues:
        issue_url = issue.get('web_url')
        issue_number = issue.get('number')
        issue_description = issue.get('body', '')
        title = issue.get('title', f'Issue {issue_number}')
        if seen_issues_db.has_seen(issue_url):
            logger.info('Skipping already processed issue #%s: %s', issue_number, title)
            continue

        branch_name = generate_branch_name(issue_number, title)
        with tempfile.TemporaryDirectory() as tmpdirname:
            issue_resolution = solve_issue_in_repository(
                repository_config,
                Path(tmpdirname),
                branch_name,
                title,
                issue_description,
                issue_number,
                client,
            )

        if issue_resolution.success:
            seen_issues_db.mark_as_seen(issue_url, str(issue_number))
            seen_issues_db.update_pr_info(
                issue_url,
                issue_resolution.pull_request_id,
                issue_resolution.pull_request_url,
            )
            logger.info(
                'Stored PR #%s information for issue #%s',
                issue_resolution.pull_request_id,
                issue_number,
            )