# Copyright 2026 François TUMUSAVYEYESU.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Utility functions for Zenith Analyser.
"""

import re
from datetime import datetime, timedelta
from typing import Any, Dict, List
import math
from zoneinfo import ZoneInfo

from .constants import DATE_FORMAT, DATETIME_FORMAT, POINT_MULTIPLIERS, TIME_FORMAT
from .exceptions import ZenithTimeError, ZenithError
from ics import Calendar, Event
from .unparser import ASTUnparser


def point_to_minutes(point: str) -> int:
    """
    Convert a Zenith point (format Y.M.D.H.M) to minutes.

    Args:
        point: Point in Zenith format (e.g., "30.0.0" for 30 days)

    Returns:
        Total number of minutes

    Raises:
        ZenithTimeError: If point format is invalid
        ValueError: If any part is negative

    Examples:
        >>>  point_to_minutes("1.0")
        60
        >>> point_to_minutes("0.1.30")
        90
        >>> point_to_minutes("30.0.0")
        43200  # 30 days
    """
    if not point:
        raise ZenithTimeError("Point cannot be empty")

    # Handle negative points
    is_negative = point.startswith("-")
    if is_negative:
        point = point[1:]

    parts = point.split(".")

    # Validate parts
    for part in parts:
        if not part.isdigit():
            raise ZenithTimeError(f"Invalid point part: '{part}' in '{point}'")

    # Convert to minutes
    total_minutes = 0
    for i, part in enumerate(reversed(parts)):
        if i >= len(POINT_MULTIPLIERS):
            raise ZenithTimeError(f"Point has too many parts: '{point}'")

        value = int(part)
        if value < 0:
            raise ZenithTimeError(
                f"Point part cannot be negative: '{value}' in '{point}'"
            )

        total_minutes += value * POINT_MULTIPLIERS[i]

    # Validate reasonable duration (max 1000 years)
    max_minutes = 518400 * 1000  # 1000 years
    if total_minutes > max_minutes:
        raise ZenithTimeError(
            f"Duration too large: {total_minutes} minutes "
            f"({total_minutes/518400:.1f} years). Check point format: '{point}'"
        )

    return -total_minutes if is_negative else total_minutes


def minutes_to_point(total_minutes: int | float) -> str:
    """
    Convert minutes to a Zenith point (format Y.M.D.H.M).

    Args:
        total_minutes: Total number of minutes

    Returns:
        Point in Zenith format

    Raises:
        ZenithTimeError: If total_minutes is invalid

    Examples:
        >>> minutes_to_point(60)
        '1.0'
        >>> minutes_to_point(90)
        '0.1.30'
        >>> minutes_to_point(43200)
        '30.0.0'  # 30 days
    """
    if not isinstance(total_minutes, (int, float)):
        raise ZenithTimeError(
            f"Total minutes must be a number, got {type(total_minutes)}"
        )

    if math.isnan(total_minutes) or math.isinf(total_minutes):
        raise ZenithTimeError("Total minutes cannot be NaN or infinite")

    # Handle negative values
    if total_minutes < 0:
        return "0"

    remaining_minutes = int(total_minutes)

    if remaining_minutes == 0:
        return "0"

    parts = []

    # Process from most significant to least significant
    for multiplier in reversed(POINT_MULTIPLIERS):
        count = remaining_minutes // multiplier

        # Add part if it's non-zero OR if we already have some parts
        if count == 0 and len(parts) > 0:
            parts.append(str(count))
        elif count > 0:
            parts.append(str(count))
            remaining_minutes -= count * multiplier

    # If no parts were added, return "0"
    if not parts:
        return "0"

    # Join with dots
    point = ".".join(parts)

    return point


def parse_datetime(date_str: str, time_str: str) -> datetime:
    """
    Parse date and time strings into a datetime object.

    Args:
        date_str: Date string in YYYY-MM-DD format
        time_str: Time string in HH:MM format

    Returns:
        datetime object

    Raises:
        ZenithTimeError: If date or time format is invalid
    """
    try:
        return datetime.strptime(f"{date_str} {time_str}", DATETIME_FORMAT)
    except ValueError as e:
        raise ZenithTimeError(f"Invalid date/time format: {date_str} {time_str}") from e


def format_datetime(dt: datetime) -> Dict[str, str]:
    """
    Format a datetime object into date and time strings.

    Args:
        dt: datetime object

    Returns:
        Dictionary with 'date' and 'time' keys
    """
    return {"date": dt.strftime(DATE_FORMAT), "time": dt.strftime(TIME_FORMAT)}


def calculate_duration(start: datetime, end: datetime) -> int:
    """
    Calculate duration in minutes between two datetimes.

    Args:
        start: Start datetime
        end: End datetime

    Returns:
        Duration in minutes

    Raises:
        ZenithTimeError: If end is before start
    """
    if end < start:
        raise ZenithTimeError(f"End time {end} is before start time {start}")

    duration = end - start
    return int(duration.total_seconds() // 60)


def add_minutes_to_datetime(dt: datetime, minutes: int) -> datetime:
    """
    Add minutes to a datetime.

    Args:
        dt: datetime object
        minutes: Number of minutes to add (can be negative)

    Returns:
        New datetime object
    """
    return dt + timedelta(minutes=minutes)


def validate_identifier(identifier: str) -> bool:
    """
    Validate an identifier according to Zenith rules.

    Args:
        identifier: Identifier to validate

    Returns:
        True if valid, False otherwise
    """
    return bool(re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", identifier))


def validate_date(date_str: str) -> bool:
    """
    Validate a date string.

    Args:
        date_str: Date string to validate

    Returns:
        True if valid, False otherwise
    """
    try:
        datetime.strptime(date_str, DATE_FORMAT)
        return True
    except ValueError:
        return False


def validate_time(time_str: str) -> bool:
    """
    Validate a time string.

    Args:
        time_str: Time string to validate

    Returns:
        True if valid, False otherwise
    """
    try:
        datetime.strptime(time_str, TIME_FORMAT)
        return True
    except ValueError:
        return False


def validate_point(point_str: str) -> bool:
    """
    Validate a point string.

    Args:
        point_str: Point string to validate

    Returns:
        True if valid, False otherwise
    """
    if not point_str:
        return False

    # Handle negative points
    if point_str.startswith("-"):
        point_str = point_str[1:]

    parts = point_str.split(".")

    # Check each part
    for part in parts:
        if not part.isdigit():
            return False

    # Check maximum parts (5: minutes, hours, days, months, years)
    if len(parts) > 5:
        return False

    return True


def deep_merge_dicts(dict1: Dict[str, Any], dict2: Dict[str, Any]) -> Dict[str, Any]:
    """
    Deep merge two dictionaries.

    Args:
        dict1: First dictionary
        dict2: Second dictionary

    Returns:
        Merged dictionary
    """
    result = dict1.copy()

    for key, value in dict2.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge_dicts(result[key], value)
        else:
            result[key] = value

    return result


def flatten_list(nested_list: List[Any]) -> List[Any]:
    """
    Flatten a nested list.

    Args:
        nested_list: Nested list to flatten

    Returns:
        Flattened list
    """
    result = []

    for item in nested_list:
        if isinstance(item, list):
            result.extend(flatten_list(item))
        else:
            result.append(item)

    return result


def safe_get(dictionary: Dict[str, Any], keys: List[str], default: Any = None) -> Any:
    """
    Safely get a value from a nested dictionary.

    Args:
        dictionary: Dictionary to search
        keys: List of keys to traverse
        default: Default value if key not found

    Returns:
        Value or default
    """
    current = dictionary

    for key in keys:
        if isinstance(current, dict) and key in current:
            current = current[key]
        else:
            return default

    return current


def format_duration(minutes: int) -> str:
    """
    Format minutes into a human-readable string.

    Args:
        minutes: Duration in minutes

    Returns:
        Human-readable duration string
    """
    if minutes == 0:
        return "0 minutes"

    # Using your custom multipliers: 518400 minutes = 1 year (360 days)
    years = minutes // 518400
    months = (minutes % 518400) // 43200
    days = (minutes % 43200) // 1440
    hours = (minutes % 1440) // 60
    mins = minutes % 60

    parts = []

    if years > 0:
        parts.append(f"{years} year{'s' if years > 1 else ''}")
    if months > 0:
        parts.append(f"{months} month{'s' if months > 1 else ''}")
    if days > 0:
        parts.append(f"{days} day{'s' if days > 1 else ''}")
    if hours > 0:
        parts.append(f"{hours} hour{'s' if hours > 1 else ''}")
    if mins > 0:
        parts.append(f"{mins} minute{'s' if mins > 1 else ''}")

    return ", ".join(parts)


def validate_zenith_code(code: str) -> List[str]:
    """
    Validate Zenith code for common issues.

    Args:
        code: Zenith code to validate

    Returns:
        List of validation errors
    """
    errors = []

    if not code:
        errors.append("Code is empty")
        return errors

    # Check for unclosed blocks
    law_count = code.count("law")
    end_law_count = code.count("end_law")
    target_count = code.count("target")
    end_target_count = code.count("end_target")

    if law_count != end_law_count:
        errors.append(
            f"Mismatched law blocks: {law_count} law vs {end_law_count} end_law"
        )

    if target_count != end_target_count:
        errors.append(
            f"Mismatched target blocks: {target_count} target vs "
            f"{end_target_count} end_target"
        )

    # Check for common syntax errors
    if "::" in code:
        errors.append("Double colon found (should be single colon)")

    if '""' in code:
        errors.append("Empty string found")

    # Check for missing colons after keywords
    keywords = [
        "law",
        "target",
        "start_date",
        "period",
        "Event",
        "GROUP",
        "key",
        "dictionnary",
    ]

    for keyword in keywords:
        pattern = rf"\b{keyword}\b\s*[^\s:]"
        if re.search(pattern, code):
            errors.append(f"Missing colon after '{keyword}'")

    return errors

def load_corpus(path:str) -> str:
    """
    Read corpus set from a provided path of the file

    Args:
        path:Provided path of the file which contains corpus set.

    Returns:
          code where contains zenith datas in zenith language.
    """
    parts = []
    if isinstance(path, str):
        parts = path.split(".")

    if parts and parts[-1] not in ["zth","znth","zenith"]:
        raise ZenithError(
            "Error extension of file corpus "
            "set...('.zth','.zenith','.znth')")

    with open(path, "r", encoding="utf-8") as file:
        code = file.read()

    return code

def load_ics(path:str):
    """
    Read calendar from a provided path of the file ics

    Args:
        path:Provided path of the file ics which contains calendar.

    Returns:
          code where contains zenith datas in zenith language.
    """

    parts=[]
    if isinstance(path, str):
        parts = path.split(".")

    if parts and parts[-1] not in ["ics"]:
        raise ZenithError(
            "Error extension of file ics "
            "set...('.ics',)"
        )
    with open(path, "r", encoding="utf-8") as f:
        calendar = Calendar(f.read())

    events =[]

    for e in calendar.events:
        events.append({
            "event_name":e.name,
            "start":e.begin.datetime.replace(tzinfo=None),
            "end":e.end.datetime.replace(tzinfo=None)
        })


    corpus = load_simulations(simulations=events)

    return corpus

def export_zenith(simulations:List) -> str:
    """
    Read simulations times

    Args:
        path:Provided simulations times.

    Returns:
          code where contains zenith datas in zenith language.
    """

    events =[]

    for sim in simulations:
        events.append({
            "event_name":sim["event_name"],
            "start":parse_datetime(
                sim["start"]["date"],
                sim["start"]["time"]
            ),
            "end":parse_datetime(
                sim["end"]["date"],
                sim["end"]["time"]
            )
        })

    return load_simulations(simulations=events)

def load_simulations(simulations:List) -> str:
    """
    Read simulations times

    Args:
        path:Provided simulations times.

    Returns:
          code where contains zenith datas in zenith language.
    """

    if not simulations:
        raise ZenithError(
            message="Provide simulations"
        )


    events = simulations

    times_points = sorted({e["start"] for e in events}.union({e["end"] for e in events}))

    segments = []

    for i in range(len(times_points)-1):
        start = times_points[i]
        end = times_points[i+1]

        if start >= end:
            continue

        duration_minutes = (end - start).total_seconds()/60

        active = [
            e["event_name"] for e in events
            if e["start"] <= start and e["end"] >= end
        ]

        if not active:
            continue

        event_name = "|".join(sorted(active))


        segments.append({
            "event_name":event_name,
            "start":start,
            "end":end,
            "duration_minutes":duration_minutes
        }
        )

    uniques = set()

    for e in events:
        es = e["event_name"].split("|")
        for s in es:
            uniques.add(s)

    unique_events = set(e for e in uniques)

    dictionnary = []

    for i, e in enumerate(list(unique_events)):
        dictionnary.append({
            "name":f"event_{i+1}",
            "description":e
        })

    for i_seg, seg in enumerate(segments):
        for item in dictionnary:
            events = seg["event_name"].split("|")
            es = []
            for e in events:
                if e == item["description"]:
                    e = item["name"]
                es.append(e)
            segments[i_seg]["event_name"] = "|".join(es)

    group = []

    for i in range(len(segments)):

        group_element ={}
        coherence = int(segments[i]["duration_minutes"])
        dispersal = int(
            (segments[i + 1]["start"] - segments[i]["end"]).total_seconds()/60
            if i < len(segments) -1 else 0
            )
        name = segments[i]["event_name"]
        group_element["name"] = name
        group_element["chronocoherence"] = minutes_to_point(coherence)
        group_element["chronodispersal"] = minutes_to_point(dispersal)

        group.append(group_element)


    start_date = segments[0]["start"].strftime("%Y-%m-%d")
    start_time = segments[0]["start"].strftime("%H:%M")

    period_minutes = 0

    for gr in group:
        period_minutes += point_to_minutes(gr["chronocoherence"]) + point_to_minutes(gr["chronodispersal"])
    period = minutes_to_point(period_minutes)

    ast = {
        "type":"Corpus_textuel",
        "elements":[
            {
                "type":"law",
                "name":"name_law",
                "contents":{
                    "start_date":{
                        "date":start_date,
                        "time":start_time
                    },
                    "period":period,
                    "events":dictionnary,
                    "group":group

                }
            }
        ]
    }
    unparse = ASTUnparser(ast)
    corpus = unparse.unparse()

    return corpus


def simulations_ics(simulations: List) -> str:
    """
    Read simulations times

    Args:
        path:Provided simulations times.

    Returns:
          code where contains ics code for calendar.
    """
    if not simulations:
        raise ZenithError("Simulations provided are empty.")

    active_events: Dict[str, Dict] = {}
    final_events = []

    for i, sim in enumerate(simulations):
        start = parse_datetime(sim["start"]["date"], sim["start"]["time"])
        end   = parse_datetime(sim["end"]["date"],   sim["end"]["time"])

        current_events = set(sim["event_name"].split("|"))

        if i > 0:
            previous_end = parse_datetime(
                simulations[i-1]["end"]["date"],
                simulations[i-1]["end"]["time"]
            )
            dispersal = int((start - previous_end).total_seconds() / 60)
        else:
            dispersal = None

        for ev in list(active_events.keys()):
            if ev not in current_events:
                final_events.append(active_events.pop(ev))

        for ev in current_events:
            if ev in active_events:

                if dispersal == 0:
                    active_events[ev]["end"] = end
                else:
                    final_events.append(active_events.pop(ev))
                    active_events[ev] = {
                        "event_name": ev,
                        "start": start,
                        "end": end
                    }
            else:
                active_events[ev] = {
                    "event_name": ev,
                    "start": start,
                    "end": end
                }


    final_events.extend(active_events.values())

    lines = [
        "BEGIN:VCALENDAR",
        "VERSION:2.0",
        "PRODID:-//Zenith Analyser//Chrononomie//FR",
        "CALSCALE:GREGORIAN"
    ]

    now = datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')

    for i, ev in enumerate(final_events):
        lines.extend([
            "BEGIN:VEVENT",
            f"UID:zenith-{i}@chrononomie",
            f"DTSTAMP:{now}",
            f"DTSTART:{ev['start'].strftime('%Y%m%dT%H%M%SZ')}",
            f"DTEND:{ev['end'].strftime('%Y%m%dT%H%M%SZ')}",
            f"SUMMARY:{ev['event_name']}",
            "END:VEVENT"
        ])

    lines.append("END:VCALENDAR")
    return "\n".join(lines)


def zenith_to_local(
        zenith_date:str,
        local_tz:str,
        orginal_tz :str="UTC")->str:
    """
    Convert a Zenith datetime (UTC implicit ,'YYYY-MM-DD at HH:MM')
    into a local datetime string in the same format.

    Args:
        zenith_date (str):e.g '2026-02-10 at 08:00'
        local_tz (str):e.g 'Africa/Bujumbura'
        local_tz (str):e.g 'Africa/Bujumbura'

    Returns:
            str: e.g, '2026-02-10 at 10:00'
    """

    ZENITH_FM ="%Y-%m-%d %H:%M"

    utc_dt = datetime.strptime(zenith_date, ZENITH_FM)
    utc_dt = utc_dt.replace(tzinfo=ZoneInfo(orginal_tz ))

    local_dt = utc_dt.astimezone(ZoneInfo(local_tz))

    return local_dt.strftime(ZENITH_FM)


def simulations_timezone(
    simulations:List,
    local_tz:str,
    orginal_tz :str="UTC")->List:

    """
    Convert a simulation with specific timezone datetime
    into a simulation with local datetime string .

    Args:
        simulations (List):Provided simulations
        local_tz (str):e.g 'Africa/Bujumbura'
        orginal_tz (str):Initialized to "UTC"

    Returns:
            str: code zenith for summary law (name_law)
    """

    events =[]
    ZENITH_FM ="%Y-%m-%d %H:%M"

    for sim in simulations:
        start = parse_datetime(
                sim["start"]["date"],
                sim["start"]["time"]
            )
        end = parse_datetime(
                sim["end"]["date"],
                sim["end"]["time"]
            )
        start = zenith_to_local(
            zenith_date=start.strftime(ZENITH_FM),
            local_tz=local_tz,
            orginal_tz=orginal_tz
        )
        end = zenith_to_local(
            zenith_date=end.strftime(ZENITH_FM),
            local_tz=local_tz,
            orginal_tz=orginal_tz
        )
        events.append({
            "event_name":sim["event_name"],
            "start":datetime.strptime(start, ZENITH_FM),
            "end":datetime.strptime(end, ZENITH_FM)
        })

    return load_simulations(simulations=events)

def format_code(code: str) -> str:
        """
        Format Zenith code with consistent indentation.

        Args:
            code: Raw Zenith code

        Returns:
            Formatted Zenith code
        """
        from .lexer import Lexer
        from .parser import Parser
        lexer = Lexer(code)
        tokens = lexer.tokenise()
        parser = Parser(tokens)
        ast = parser.parse()[0]

        unparser = ASTUnparser(ast)
        code = unparser.unparse()

        return code
