#!/usr/bin/env python3

# Libervia plugin to handle forges.
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from datetime import date as dt_date
from typing import Iterator, cast

from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator
from sqlalchemy import literal_column, select
from sqlalchemy.dialects.sqlite import insert
from twisted.internet import defer

from libervia.backend import G
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
from libervia.backend.memory.sqla_mapping import PrivateInd
from libervia.backend.models.types import JIDType
from libervia.backend.tools.config import ConfigOptions, DirectoryPath, PluginSettings
from libervia.backend.tools.utils import aio

log = getLogger(__name__)


PLUGIN_INFO = {
    C.PI_NAME: "Forge",
    C.PI_IMPORT_NAME: "FORGE",
    C.PI_TYPE: C.PLUG_TYPE_MISC,
    C.PI_DEPENDENCIES: [],
    C.PI_MAIN: "Forge",
    C.PI_HANDLER: "no",
    C.PI_DESCRIPTION: _("""Forge for developping projects."""),
}


class TicketsNode(ConfigOptions):
    service: JIDType
    node: str


class Repository(ConfigOptions):
    name: str
    path: DirectoryPath
    tickets: TicketsNode | None = None


class Repositories(RootModel):
    root: list[Repository]

    def __iter__(self) -> Iterator[Repository]:  # type: ignore
        return iter(self.root)

    def __len__(self) -> int:
        return len(self.root)


class ProfileRepositories(RootModel):
    root: dict[str, Repositories]

    def __getitem__(self, item: str) -> Repositories:
        return self.root[item]


class DefaultRepository(BaseModel):
    profile: str
    repository: str


class CloneCounterRequest(BaseModel):
    """Counter statitics request.

    Counters are stored daily. Query can be filtered on specific date or date range. Don't
    specify any date to get all counters of the repository.
    """

    model_config = ConfigDict(extra="forbid")

    date: dt_date | None = Field(
        default=None,
        description="Specific date to retrieve (YYYY-MM-DD format)",
    )
    start_date: dt_date | None = Field(
        default=None,
        description="Start date for range query (inclusive, YYYY-MM-DD format)",
    )
    end_date: dt_date | None = Field(
        default=None,
        description="End date for range query (inclusive, YYYY-MM-DD format)",
    )

    @field_validator("date")
    @classmethod
    def validate_date_exclusive(cls, v: dt_date | None, info):
        """date cannot be used together with start_date/end_date."""
        if v is not None:
            data = info.data
            if data.get("start_date") is not None or data.get("end_date") is not None:
                raise ValueError("date cannot be used together with start_date/end_date")
        return v


class CloneCounter(BaseModel):
    """Clone counter statistics for a repository on a specific date."""

    repository: str = Field(description="Repository name")
    date: str = Field(description="Date in YYYY-MM-DD format")
    count: int = Field(description="Number of clones")


class CloneCounters(RootModel):
    root: list[CloneCounter]

    def __iter__(self) -> Iterator[CloneCounter]:  # type: ignore
        return iter(self.root)

    def append(self, item: CloneCounter) -> None:
        self.root.append(item)

    def __len__(self) -> int:
        return len(self.root)


class PluginForgeSettings(PluginSettings, plugin_info=PLUGIN_INFO):
    repositories: ProfileRepositories = Field(
        default_factory=lambda: ProfileRepositories({})
    )
    default: DefaultRepository | None = None


class Forge:
    def __init__(self, host):
        log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.")
        self.settings = G.config.get_settings(PluginForgeSettings)
        self.host = host

        host.bridge.add_method(
            "forge_repositories_get",
            ".plugin",
            in_sign="sss",
            out_sign="s",
            method=self._repositories_get,
            async_=True,
        )
        host.bridge.add_method(
            "forge_default_repository_get",
            ".plugin",
            in_sign="",
            out_sign="s",
            method=self._default_repository_get,
            async_=True,
        )
        host.bridge.add_method(
            "forge_clone_counter_update",
            ".plugin",
            in_sign="ss",
            out_sign="",
            method=self._update_clone_counter,
            async_=True,
        )
        host.bridge.add_method(
            "forge_clone_counter_get",
            ".plugin",
            in_sign="ssss",
            out_sign="s",
            method=self._get_clone_counter,
            async_=True,
        )

    def _repositories_get(
        self, target_profile: str, data_s: str, profile: str
    ) -> defer.Deferred[str]:
        client = self.host.get_client(profile)
        repositories = self.repositories_get(client, target_profile)
        return defer.succeed(repositories.model_dump_json())

    def repositories_get(
        self, client: SatXMPPEntity, target_profile: str
    ) -> Repositories:
        """Retrieve repositories of a profile.
        @param client: Client instance.
        @param target_profile: Profile to retrieve repositories from.
        @return: List of repositories.
        @raise exceptions.NotFound: No repository found.
        """
        try:
            return self.settings.repositories[target_profile]
        except KeyError:
            raise exceptions.NotFound(f"No repository found for {target_profile!r}.")

    def _default_repository_get(self) -> defer.Deferred[str]:
        default = self.settings.default
        if default is None:
            raise exceptions.NotFound("No default repository set.")
        else:
            return defer.succeed(default.model_dump_json())

    @aio
    async def _update_clone_counter(
        self, target_profile: str, repository_name: str
    ) -> None:
        """Update clone counter for a repository

        Counters are incremented per-day. Local timezone is used to determine the day.

        @param target_profile: name of the profile owning the repository
        @param repository_name: name of the repository
        """
        key = f"counter:{repository_name}:{dt_date.today().isoformat()}"
        namespace = PLUGIN_INFO[C.PI_NAME]

        async with G.storage.session() as session:
            stmt = (
                insert(PrivateInd)
                .values(
                    namespace=namespace,
                    key=key,
                    profile_id=G.storage.profiles[target_profile],
                    value="1",
                )
                .on_conflict_do_update(
                    index_elements=[
                        PrivateInd.namespace,
                        PrivateInd.key,
                        PrivateInd.profile_id,
                    ],
                    set_={"value": literal_column("CAST(value AS INTEGER) + 1")},
                )
            )
            await session.execute(stmt)
            await session.commit()

    def _get_clone_counter(
        self,
        target_profile: str,
        repository_name: str,
        request_s: str,
        profile: str,
    ) -> defer.Deferred[str]:
        client = self.host.get_client(profile)
        request = CloneCounterRequest.model_validate_json(request_s)
        d = defer.ensureDeferred(
            self.get_clone_counter(client, target_profile, repository_name, request)
        )
        d.addCallback(lambda stats_list: stats_list.model_dump_json())
        d = cast(defer.Deferred[str], d)
        return d

    @aio
    async def get_clone_counter(
        self,
        client: SatXMPPEntity,
        target_profile: str,
        repository_name: str,
        request: CloneCounterRequest,
    ) -> CloneCounters:
        """Retrieve clone counter.

        @param client: Client instance.
        @param target_profile: Profile to get counters for.
        @param repository_name: Name of the repository.
        @param request: Which counters to retrieve.
        @return: List of clone counters.
        @raise exceptions.NotFound: The requested repository can't be found.
        """
        repositories = self.repositories_get(client, target_profile)
        try:
            next(r for r in repositories if r.name == repository_name)
        except StopIteration:
            raise exceptions.NotFound(
                f"{target_profile} has not repository named {repository_name}."
            )
        namespace = PLUGIN_INFO[C.PI_NAME]
        profile_id = G.storage.profiles[target_profile]
        key_prefix = f"counter:{repository_name}:"

        # FIXME: We use PrivateInd which has text values, and we use keys to store the
        #   date. This is not ideal. In future, dedicated models/DB tables should probably
        #   be used.

        stmt = (
            select(PrivateInd)
            .where(
                PrivateInd.namespace == namespace,
                PrivateInd.profile_id == profile_id,
            )
            .order_by(PrivateInd.key)
        )

        if request.date is not None:
            key = f"{key_prefix}{request.date.isoformat()}"
            stmt = stmt.where(PrivateInd.key == key)
        else:
            if request.start_date is not None:
                start_key = f"{key_prefix}{request.start_date.isoformat()}"
                stmt = stmt.where(PrivateInd.key >= start_key)
            if request.end_date is not None:
                end_key = f"{key_prefix}{request.end_date.isoformat()}"
                stmt = stmt.where(PrivateInd.key <= end_key)
            stmt = stmt.where(PrivateInd.key.like(f"{key_prefix}%"))

        async with G.storage.session() as session:
            result = await session.execute(stmt)

        rows = result.scalars().all()

        counters = CloneCounters([])
        for row in rows:
            date_str = row.key[len(key_prefix) :]
            counters.append(
                CloneCounter(
                    repository=repository_name,
                    date=date_str,
                    count=int(row.value),
                )
            )

        return counters
